diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000..0556c925ae --- /dev/null +++ b/.flake8 @@ -0,0 +1,49 @@ +[flake8] +exclude = .git,.tox,__pycache__,doc,old,build,dist +show_source = True +statistics = True +verbose = 2 +select = + E101, + E112, + E122, + E125, + E133, + E223, + E224, + E242, + E273, + E274, + E901, + E902, + W191, + W601, + W602, + W603, + W604, + E124, + E231, + E211, + E261, + E271, + E272, + E304, + F401, + F402, + F403, + F404, + E501, + E502, + E702, + E703, + E711, + E712, + E721, + F811, + F812, + F821, + F822, + F823, + F831, + F841, + W292 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..85ea5b79e7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + + + + +### Short description + + +### Code to reproduce + +```python +import pyqtgraph as pg +import numpy as np +``` + +### Expected behavior + + +### Real behavior + + +``` +An error occurred? +Post the full traceback inside these 'code fences'! +``` + +### Tested environment(s) + + * PyQtGraph version: + * Qt Python binding: + * Python version: + * NumPy version: + * Operating system: + * Installation method: + +### Additional context diff --git a/.mailmap b/.mailmap deleted file mode 100644 index 025cf940e1..0000000000 --- a/.mailmap +++ /dev/null @@ -1,12 +0,0 @@ -Luke Campagnola Luke Campagnola <> -Luke Campagnola Luke Campagnola -Megan Kratz meganbkratz@gmail.com <> -Megan Kratz Megan Kratz -Megan Kratz Megan Kratz -Megan Kratz Megan Kratz -Megan Kratz Megan Kratz -Megan Kratz Megan Kratz -Megan Kratz Megan Kratz -Ingo Breßler Ingo Breßler -Ingo Breßler Ingo B. - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..c2f8f9a8b6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + sha: master + hooks: + - id: check-added-large-files + args: ['--maxkb=100'] + - id: check-case-conflict + - id: end-of-file-fixer + - id: fix-encoding-pragma + - id: mixed-line-ending + args: [--fix=lf] diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000000..795d359a5a --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,12 @@ +# Read the Docs configuration file +# https://docs.readthedocs.io/en/stable/config-file/v2.html + +version: 2 + +python: + version: 3 + install: + - requirements: doc/requirements.txt + +sphinx: + fail_on_warning: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a4d85e47a1..0000000000 --- a/.travis.yml +++ /dev/null @@ -1,194 +0,0 @@ -language: python -sudo: false -# Credit: Original .travis.yml lifted from VisPy - -# Here we use anaconda for 2.6 and 3.3, since it provides the simplest -# interface for running different versions of Python. We could also use -# it for 2.7, but the Ubuntu system has installable 2.7 Qt4-GL, which -# allows for more complete testing. -notifications: - email: false - -env: - # Enable python 2 and python 3 builds - # Note that the 2.6 build doesn't get flake8, and runs old versions of - # Pyglet and GLFW to make sure we deal with those correctly - - PYTHON=2.6 QT=pyqt4 TEST=standard - - PYTHON=2.7 QT=pyqt4 TEST=extra - - PYTHON=2.7 QT=pyside TEST=standard - - PYTHON=3.4 QT=pyqt5 TEST=standard - # - PYTHON=3.4 QT=pyside TEST=standard # pyside isn't available for 3.4 with conda - #- PYTHON=3.2 QT=pyqt5 TEST=standard - - -before_install: - - if [ ${TRAVIS_PYTHON_VERSION:0:1} == "2" ]; then wget http://repo.continuum.io/miniconda/Miniconda-3.5.5-Linux-x86_64.sh -O miniconda.sh; else wget http://repo.continuum.io/miniconda/Miniconda3-3.5.5-Linux-x86_64.sh -O miniconda.sh; fi - - chmod +x miniconda.sh - - ./miniconda.sh -b -p /home/travis/mc - - export PATH=/home/travis/mc/bin:$PATH - - # not sure what is if block is for - - if [ "${TRAVIS_PULL_REQUEST}" != "false" ]; then - GIT_TARGET_EXTRA="+refs/heads/${TRAVIS_BRANCH}"; - GIT_SOURCE_EXTRA="+refs/pull/${TRAVIS_PULL_REQUEST}/merge"; - else - GIT_TARGET_EXTRA=""; - GIT_SOURCE_EXTRA=""; - fi; - - # to aid in debugging - - echo ${TRAVIS_BRANCH} - - echo ${TRAVIS_REPO_SLUG} - - echo ${GIT_TARGET_EXTRA} - - echo ${GIT_SOURCE_EXTRA} - -install: - - export GIT_FULL_HASH=`git rev-parse HEAD` - - conda update conda --yes - - conda create -n test_env python=${PYTHON} --yes - - source activate test_env - - conda install numpy pyopengl pytest flake8 six coverage --yes - - echo ${QT} - - echo ${TEST} - - echo ${PYTHON} - - - if [ "${QT}" == "pyqt5" ]; then - conda install pyqt --yes; - fi; - - if [ "${QT}" == "pyqt4" ]; then - conda install pyqt=4 --yes; - fi; - - if [ "${QT}" == "pyside" ]; then - conda install pyside --yes; - fi; - - pip install pytest-xdist # multi-thread py.test - - pip install pytest-cov # add coverage stats - - # required for example testing on python 2.6 - - if [ "${PYTHON}" == "2.6" ]; then - pip install importlib; - fi; - - # Debugging helpers - - uname -a - - cat /etc/issue - - if [ "${PYTHON}" == "2.7" ]; then - python --version; - else - python3 --version; - fi; - -before_script: - # We need to create a (fake) display on Travis, let's use a funny resolution - - export DISPLAY=:99.0 - - "sh -e /etc/init.d/xvfb start" - - /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1400x900x24 -ac +extension GLX +render - - # Make sure everyone uses the correct python (this is handled by conda) - - which python - - python --version - - pwd - - ls - # Help color output from each test - - RESET='\033[0m'; - RED='\033[00;31m'; - GREEN='\033[00;32m'; - YELLOW='\033[00;33m'; - BLUE='\033[00;34m'; - PURPLE='\033[00;35m'; - CYAN='\033[00;36m'; - WHITE='\033[00;37m'; - start_test() { - echo -e "${BLUE}======== Starting $1 ========${RESET}"; - }; - check_output() { - ret=$?; - if [ $ret == 0 ]; then - echo -e "${GREEN}>>>>>> $1 passed <<<<<<${RESET}"; - else - echo -e "${RED}>>>>>> $1 FAILED <<<<<<${RESET}"; - fi; - return $ret; - }; - - - if [ "${TEST}" == "extra" ]; then - start_test "repo size check"; - mkdir ~/repo-clone && cd ~/repo-clone && - git init && git remote add -t ${TRAVIS_BRANCH} origin git://github.com/${TRAVIS_REPO_SLUG}.git && - git fetch origin ${GIT_TARGET_EXTRA} && - git checkout -qf FETCH_HEAD && - git tag travis-merge-target && - git gc --aggressive && - TARGET_SIZE=`du -s . | sed -e "s/\t.*//"` && - git pull origin ${GIT_SOURCE_EXTRA} && - git gc --aggressive && - MERGE_SIZE=`du -s . | sed -e "s/\t.*//"` && - if [ "${MERGE_SIZE}" != "${TARGET_SIZE}" ]; then - SIZE_DIFF=`expr \( ${MERGE_SIZE} - ${TARGET_SIZE} \)`; - else - SIZE_DIFF=0; - fi; - fi; - -script: - - - source activate test_env - - # Check system info - - python -c "import pyqtgraph as pg; pg.systemInfo()" - - # Run unit tests - - start_test "unit tests"; - PYTHONPATH=. py.test --cov pyqtgraph -sv; - check_output "unit tests"; - - echo "test script finished. Current directory:" - - pwd - - # check line endings - - if [ "${TEST}" == "extra" ]; then - start_test "line ending check"; - ! find ./ -name "*.py" | xargs file | grep CRLF && - ! find ./ -name "*.rst" | xargs file | grep CRLF; - check_output "line ending check"; - fi; - - # Check repo size does not expand too much - - if [ "${TEST}" == "extra" ]; then - start_test "repo size check"; - echo -e "Estimated content size difference = ${SIZE_DIFF} kB" && - test ${SIZE_DIFF} -lt 100; - check_output "repo size check"; - fi; - - # Check for style issues - - if [ "${TEST}" == "extra" ]; then - start_test "style check"; - cd ~/repo-clone && - git reset -q travis-merge-target && - python setup.py style && - check_output "style check"; - fi; - - # Check install works - - start_test "install test"; - python setup.py --quiet install; - check_output "install test"; - - # Check double-install fails - # Note the bash -c is because travis strips off the ! otherwise. - - start_test "double install test"; - bash -c "! python setup.py --quiet install"; - check_output "double install test"; - - # Check we can import pg - - start_test "import test"; - echo "import sys; print(sys.path)" | python && - cd /; echo "import pyqtgraph.examples" | python; - check_output "import test"; - -after_success: - - cd /home/travis/build/pyqtgraph/pyqtgraph - - pip install codecov --upgrade # add coverage integration service - - codecov - - pip install coveralls --upgrade # add another coverage integration service - - coveralls diff --git a/CHANGELOG b/CHANGELOG index 388f51b913..efc3ee3bda 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,317 @@ +pyqtgraph-0.11.0 + + NOTICE: This is the _last_ feature release to support Python 2 and Qt 4 (PyQt4 or pyside 1) + + New Features: + - #101: GridItem formatting options + - #410: SpinBox custom formatting options + - #415: ROI.getArrayRegion supports nearest-neighbor interpolation (especially handy for label images) + - #428: DataTreeWidget: + - Add DiffTreeWidget, which highlights differences between two DataTreeWidgets + - Improved support for displaying tracebacks + - Use TableWidget to represent arrays rather than plain text + - #446: Added Perceptually Uniform Sequential colormaps from the matplotlib 2.0 release + - #476: Add option to set composition mode for scatterplotitem + - #518: TreeWidget: + - Add new signals: sigItemCheckStateChanged, sigItemTextChanged, sigColumnCountChanged + - Allow setting expansion state of items before they are added to a treewidget + - Support for using TreeWidget.invisibleRootItem() (see also #592, #595) + - #542: Add collapsible QGroupBox widgets + - #543: Add TargetItem: simple graphicsitem that draws a scale-invariant circle + crosshair + - #544: Make DockArea.restoreState behavior configurable in cases where either a dock to be restored is + missing, or an extra dock exists that is not mentioned in the restore state. + - #545: Allow more types to be mapped through Transform3D + - #548: Adds a disconnect() function that allows to conditionally disconnect signals, + including after reload. + Also, a SignalBlock class used to temporarily block a signal-slot pair + - #557: Allow console stack to be set outside of exceptions (see also: pg.stack) + - #558: CanvasItem save/restore, make Canvas ui easier to embed + - #559: Image exporter gets option to invert value while leaving hue fixed + - #560: Add function to enable faulthandler on all threads, also allow Mutex to be used as + drop-in replacement for python's Lock + - #567: Flowchart + - Add several new data nodes + - Add floordiv node + - Add EvalNode.setCode + - Binary operator nodes can select output array type + - #568: LinearRegionItem + - InfiniteLine can draw markers attached to the line + - InfiniteLine can limit the region of the viewbox over which it is drawn + - LinearRegionItem gets customizable line swap behavior (lines can block or push each other) + - Added LinearRegionItem.setHoverBrush + - #580: Allow calling sip.setapi in subprocess before pyqtgraph is imported + - #582: Add ComboBox save/restoreState methods + - #586: ParameterTree + - Add GroupParameter.sigAddNew signal + - systemsolver: add method for checking constraints / DOF + - add systemsolver copy method + - Parameter.child raises KeyError if requested child name does not exist + - #587: Make PathButton margin customizable + - #588: Add PlotCurveItem composition mode + - #589: Add RulerROI + - #591: Add nested progress dialogs + - #597: Fancy new interactive fractal demo + - #621: RGB mode for HistogramLUTWidget + - #628,670: Add point selection in ScatterPlotWidget + - #635: PySide2 support + - #671: Add SVG export option to force non-scaling stroke + - #676: OpenGL allow for panning in the plane of the camera + - #683: Allow data filter entries to be updated after they are created + - #685: Add option to set enum default values in DataFilterWidget + - #710: Adds ability to rotate/scale ROIs by mouse drag on the ROI itself (using alt/shift modifiers) + - #813,814,817: Performance improvements + - #837: Added options for field variables in ColorMapWidget + - #840, 932: Improve clipping behavior + - #841: Set color of tick-labels separately + - #922: Curve fill for fill-patches + - #996: Allow the update of LegendItem + - #1023: Add bookkeeping exporter parameters + - #1072: HDF5Exporter handling of ragged curves with tests + - #1124: Syntax highlighting for examples. + - #1154: Date axis item + - #393: NEW show/hide gradient ticks NEW link gradientEditor to others + - #1211: Add support for running pyside2-uic binary to dynamically compile ui files + + API / behavior changes: + - Deprecated graphicsWindow classes; these have been unnecessary for many years because + widgets can be placed into a new window just by calling show(). + - #158: Make DockArea compatible with Qt Designer + - #406: Applying alpha mask on numpy.nan data values + - #566: ArrowItem's `angle` option now rotates the arrow without affecting its coordinate system. + The result is visually the same, but children of ArrowItem are no longer rotated + (this allows screen-aligned text to be attached more easily). + To mimic the old behavior, use ArrowItem.rotate() instead of the `angle` argument. + - #673: Integer values in ParameterTree are now formatted as integer (%d) by default, rather than + scientific notation (%g). This can be overridden by providing `format={value:g}` when + creating the parameter. + - #374: ConsoleWidget uses the console's namespace as both global and local scope, which + - #410: SpinBox siPrefix without suffix is not longer allowed, select only numerical portion of text on focus-in + allows functions defined in the console to access the global namespace. + - #479,521: ParameterTree simple parameters check types before setting value + - #555: multiprocess using callSync='sync' no longer returns a future in case of timeout + - #583: eq() no longer compares array values if they have different shape + - #589: Remove SpiralROI (this was unintentionally added in the first case) + - #593: Override qAbort on slot exceptions for PyQt>=5.5 + - #657: When a floating Dock window is closed, the dock is now returned home + - #771: Suppress RuntimeWarning for arrays containing zeros in logscale + - #942: If the visible GraphicsView is garbage collected, a warning is issued. + - #958: Nicer Legend + - #963: Last image in image-stack can now be selected with the z-slider + - #992: Added a setter for GlGridItem.color. + - #999: Make outline around fillLevel optional. + - #1014: Enable various arguments as color in colormap. + - #1044: Raise AttributeError in __getattr__ in graphicsWindows (deprecated) + - #1055: Remove global for CONFIG_OPTIONS in setConfigOption + - #1066: Add RemoteGraphicsView to __init__.py + - #1069: Allow actions to display title instead of name + - #1074: Validate min/max text inputs in ViewBoxMenu + - #1076: Reset currentRow and currentCol on GraphicsLayout.clear() + - #1079: Improve performance of updateData PlotCurveItem + - #1082: Allow MetaArray.__array__ to accept an optional dtype arg + - #841: set color of tick-labels separately + - #1111: Add name label to GradientEditorItem + - #1145: Pass showAxRect keyword arguments to setRange + - #1184: improve SymbolAtlas.getSymbolCoords performance + - #1198: improve SymbolAtlas.getSymbolCoords and ScatterPlotItem.plot performance + - #1197: Disable remove ROI menu action in handle context menu + - #1188: Added support for plot curve to handle both fill and connect args + - #801: Remove use of GraphicsScene._addressCache in translateGraphicsItem + - Deprecates registerObject meethod of GraphicsScene + - Deprecates regstar argument to GraphicsScene.__init__ + - #1166: pg.mkQApp: Pass non-empty string array to QApplication() as default + - #1199: Pass non-empty sys.argv to QApplication + - #1090: dump ExportDialog.exporterParameters + - #1173: GraphicsLayout: Always call layout.activate() after adding items + - #1097: pretty-print log-scale axes labels + - #755: Check lastDownsample in viewTransformChanged + - #1216: Add cache for mapRectFromView + - #444: Fix duplicate menus in GradientEditorItem + - #151: Optionally provide custom PlotItem to PlotWidget + - #1093: Fix aspectRatio and zoom range issues when zooming + - #390: moved some functionality from method 'export' to new method + - #468: Patch/window handling + - #392: new method 'getAxpectRatio' with code taken from 'setAspectLocked' + - #1206: Added context menu option to parametertree + - #1228: Minor improvements to LegendItem + + Bugfixes: + - #88: Fixed image scatterplot export + - #356: Fix some NumPy warnings + - #408: Fix `cleanup` when the running qt application is not a QApplication + - #410: SpinBox fixes + - fixed bug with exponents disappearing after edit + - fixed parsing of values with junk after suffix + - fixed red border + - reverted default decimals to 6 + - make suffix editable (but show red border if it's wrong) + - revert invalid text on focus lost + - siPrefix without suffix is no longer allowed + - fixed parametree sending invalid options to spinbox + - fix spinbox wrapping (merged #159 from @lidstrom83) + - fixed parametertree ignoring spinbox bounds (merged #329 from @lidstrom83) + - fixed spinbox height too small for font size + - ROI subclass getArrayRegion methods are a bit more consistent (still need work) + - #424: Fix crash when running pyqtgraph with python -OO + - #429: Fix fft premature slicing away of 0 freq bin + - #458: Fixed image export problems with new numpy API + - #478: Fixed PySide image memory leak + - #475: Fixed unicode error when exporting to SVG with non-ascii symbols + - #477: Fix handling of the value argument to functions.intColor + - #485: Fixed incorrect height in VTickGroup + - #514: Fixes bug where ViewBox emits sigRangeChanged before it has marked its transform dirty. + - #516,668: Fix GL Views being half size on hidpi monitors + - #526: Fix autorange exception with empty scatterplot + - #528: Prevent image downsampling causing exception in makeQImage + - #530: Fixed issue where setData only updated opts if data is given + - #541: Fixed issue where render would error because 'mapToDevice' would return None if the view size was too small. + - #553: Fixed legend size after remove item + - #555: Fixed console color issues, problems with subprocess closing + - #559: HDF5 exporter: check for ragged array length + - #563: Prevent viewbox auto-scaling to items that are not in the same scene. (This could + happen if an item that was previously added to the viewbox is then removed using scene.removeItem(). + - #564: Allow console exception label to wrap text (prevents console + growing too large for long exception messages) + - #565: Fixed AxisItem preventing mouse events reaching the ViewBox if it is displaying grid lines + and has its Z value set higher than the ViewBox. + - #567: fix flowchart spinbox bounds + - #569: PlotItem.addLegend will not try to add more than once + - #570: ViewBox: make sure transform is up to date in all mapping functions + - #577: Fix bargraphitem plotting horizontal bars + - #581: Fix colormapwidget saveState + - #586: ParameterTree + - Make parameter name,value inint args go through setValue and setName + - Fix colormapwidget saveState + - #589: Fix click area for small ellipse/circle ROIs + - #592,595: Fix InvisibleRootItem issues introduced in #518 + - #596: Fix polyline click causing lines to bedrawn to the wrong node + - #598: Better ParameterTree support for dark themes + - #599: Prevent invalid list access in GraphicsScene + - #623: Fix PyQt5 / ScatterPlot issue with custom symbols + - #626: Fix OpenGL texture state leaking to wrong items + - #627: Fix ConsoleWidget stack handling on python 3.5 + - #633: Fix OpenGL cylinder geometry + - #637: Fix TypeError in isosurface + - #641,642: Fix SVG export on Qt5 / high-DPI displays + - #645: ScatterPlotWidget behaves nicely when data contains infs + - #653: ScatterPlotItem: Fix a GC memory leak due to numpy issue 6581 + - #648: fix color ignored in GLGridItem + - #671: Fixed SVG export failing if the first value of a plot is nan + - #674: Fixed parallelizer leaking file handles + - #675: Gracefully handle case where image data has size==0 + - #679: Fix overflow in Point.length() + - #682: Fix: mkQApp returned None if a QApplication was already created elsewhere + - #689: ViewBox fix: don't call setRange with empty args + - #693: Fix GLLinePlotItem setting color + - #696: Fix error when using PlotDataItem with both stepMode and symbol + - #697: Fix SpinBox validation on python 3 + - #699: Fix nan handling in ImageItem.setData + - #713: ConsoleWidget: Fixed up/down arrows sometimes unable to get back to the original + (usually blank) input state + - #715: Fix file dialog handling in Qt 5 + - #718: Fix SVG export with items that require option.exposedRect + - #721: Fixes mouse wheel ignoring disabled mouse axes -- although the scaling was correct, + it was causing auto range to be disabled. + - #723: Fix axis ticks when using self.scale + - #739: Fix handling of 2-axis mouse wheel events + - #742: Fix Metaarray in python 3 + - #758: Fix remote graphicsview "ValueError: mmap length is greater than file size" on OSX. + - #763: Fix OverflowError when using Auto Downsampling. + - #767: Fix Image display for images with the same value everywhere. + - #770: Fix GLVieWidget.setCameraPosition ignoring first parameter. + - #782: Fix missing FileForwarder thread termination. + - #787: Fix encoding errors in checkOpenGLVersion. + - #793: Fix wrong default scaling in makeARGB + - #815: Fixed mirroring of x-axis with "invert Axis" submenu. + - #824: Fix several issues related with mouse movement and GraphicsView. + - #832: Fix Permission error in tests due to unclosed filehandle. + - #836: Fix tickSpacing bug that lead to axis not being drawn. + - #861: Fix crash of PlotWidget if empty ErrorBarItem is added. + - #868: Fix segfault on repeated closing of matplotlib exporter. + - #875,876,887,934,947,980: Fix deprecation warnings. + - #886: Fix flowchart saving on python3. + - #888: Fix TreeWidget.topLevelItems in python3. + - #924: Fix QWheelEvent in RemoteGraphicsView with pyqt5. + - #935: Fix PlotItem.addLine with 'pos' and 'angle' parameter. + - #949: Fix multiline parameters (such as arrays) reading from config files. + - #951: Fix event firing from scale handler. + - #952: Fix RotateFree handle dragging + - #953: Fix HistogramLUTWidget with background parameter + - #968: Fix Si units in AxisItem leading to an incorrect unit. + - #970: Always update transform when setting angle of a TextItem + - #971: Fix a segfault stemming from incorrect signal disconnection. + - #972: Correctly include SI units for log AxisItems + - #974: Fix recursion error when instancing CtrlNode. + - #987: Fix visibility reset when PlotItems are removed. + - #998: Fix QtProcess proxy being unable to handle numpy arrays with dtype uint8. + - #1010: Fix matplotlib/CSV export. + - #1012: Fix circular texture centering + - #1015: Iterators are now converted to NumPy arrays. + - #1016: Fix synchronisation of multiple ImageViews with time axis. + - #1017: Fix duplicate paint calls emitted by Items on ViewBox. + - #1019: Fix disappearing GLGridItems when PlotItems are removed and readded. + - #1024: Prevent element-wise string comparison + - #1031: Reset ParentItem to None on removing from PlotItem/ViewBox + - #1044: Fix PlotCurveItem.paintGL + - #1048: Fix bounding box for InfiniteLine + - #1062: Fix flowchart context menu redundant menu + - #1062: Fix a typo + - #1073: Fix Python3 compatibility + - #1083: Fix SVG export of scatter plots + - #1085: Fix ofset when drawing symbol + - #1101: Fix small oversight in LegendItem + - #1113: Correctly call hasFaceIndexedData function + - #1139: Bug fix in LegendItem for `setPen`, `setBrush` etc (Call update instead of paint) + - #1110: fix for makeARGB error after #955 + - #1063: Fix: AttributeError in ViewBox.setEnableMenu + - #1151: ImageExporter py2-pyside fix with test + - #1133: compatibility-fix for py2/pyside + - #1152: Nanmask fix in makeARGB + - #1159: Fix: Update axes after data is set + - #1156: SVGExporter: Correct image pixelation + - #1169: Replace default list arg with None + - #770: Do not ignore pos argument of setCameraPosition + - #1180: Fix: AxisItem tickFont is defined in two places while only one is used + - #1168: GroupParameterItem: Did not pass changed options to ParameterItem + - #1174: Fixed a possible race condition with linked views + - #809: Fix selection of FlowchartWidget input/output nodes + - #1071: Fix py3 execution in flowchart + - #1212: Fix PixelVectors cache + - #1161: Correctly import numpy where needed + - #1218: Fix ParameterTree.clear() + - #1175: Fix: Parameter tree ignores user-set 'expanded' state + - #1219: Encode csv export header as unicode + - #507: Fix Dock close event QLabel still running with no parent + - #1222: py3 fix for ScatterPlotWidget.setSelectedFields + - #1203: Image axis order bugfix + - #1225: ParameterTree: Fix custom context menu + + + Maintenance: + - Lots of new unit tests + - Lots of code cleanup + - A lot of work on CI pipelines, test coverage and test passing (see e.g. #903,911) + - #546: Add check for EINTR during example testing to avoid sporadic test failures on travis + - #624: TravisCI no longer running python 2.6 tests + - #695: "dev0" added to version string + - #865,873,877 (and more): Implement Azure CI pipelines, fix Travis CI + - #991: Use Azure Pipelines to do style checks, Add .pre-commit-config.yaml + - #1042: Close windows at the end of test functions + - #1046: Establish minimum numpy version, remove legacy workarounds + - #1067: Make scipy dependency optional + - #1114: doc: Fix small mistake in introduction + - #1131: Update CI/tox and Enable More Tests + - #1142: Miscellaneous doc fixups + - #1179: DateAxisItem: AxisItem unlinking tests and doc fixed + - #1201: Get readthedocs working + - #1214: Pin PyVirtualDisplay Version + - #1215: Skip test when on qt 5.9 + - #1221: Identify pyqt5 5.15 ci issue + - #1223: Remove workaround for memory leak in QImage + - #1217: Get docs version and copyright year dynamically + - #1229: Wrap text in tables in docs + - #1231: Update readme for 0.11 release + + pyqtgraph-0.10.0 New Features: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..461e9b1400 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to PyQtGraph + +Contributions to pyqtgraph are welcome! + +Please use the following guidelines when preparing changes: + +## Submitting Code Changes + +* The preferred method for submitting changes is by github pull request against the "develop" branch. +* Pull requests should include only a focused and related set of changes. Mixed features and unrelated changes may be rejected. +* For major changes, it is recommended to discuss your plans on the mailing list or in a github issue before putting in too much effort. +* The following deprecations are being considered by the maintainers + * `pyqtgraph.opengl` may be deprecated and replaced with `VisPy` functionality + * After v0.11, pyqtgraph will adopt [NEP-29](https://numpy.org/neps/nep-0029-deprecation_policy.html) which will effectively mean that python2 support will be deprecated + * Qt4 will be deprecated shortly, as well as Qt5<5.9 (and potentially <5.12) + +## Documentation + +* Writing proper documentation and unit tests is highly encouraged. PyQtGraph uses pytest style testing, so tests should usually be included in a tests/ directory adjacent to the relevant code. +* Documentation is generated with sphinx; please check that docstring changes compile correctly + +## Style guidelines + +### Rules + +* PyQtGraph prefers PEP8 for most style issues, but this is not enforced rigorously as long as the code is clean and readable. +* Use `python setup.py style` to see whether your code follows the mandatory style guidelines checked by flake8. +* Exception 1: All variable names should use camelCase rather than underscore_separation. This is done for consistency with Qt +* Exception 2: Function docstrings use ReStructuredText tables for describing arguments: + + ```text + ============== ======================================================== + **Arguments:** + argName1 (type) Description of argument + argName2 (type) Description of argument. Longer descriptions must + be wrapped within the column guidelines defined by the + "====" header and footer. + ============== ======================================================== + ``` + + QObject subclasses that implement new signals should also describe + these in a similar table. + +### Pre-Commit + +PyQtGraph developers are highly encouraged to (but not required) to use [`pre-commit`](https://pre-commit.com/). `pre-commit` does a number of checks when attempting to commit the code to ensure it conforms to various standards, such as `flake8`, utf-8 encoding pragma, line-ending fixers, and so on. If any of the checks fail, the commit will be rejected, and you will have the opportunity to make the necessary fixes before adding and committing a file again. This ensures that every commit made conforms to (most) of the styling standards that the library enforces; and you will most likely pass the code style checks by the CI. + +To make use of `pre-commit`, have it available in your `$PATH` and run `pre-commit install` from the root directory of PyQtGraph. + +## Testing Setting up a test environment + +### Dependencies + +* tox +* tox-conda +* pytest +* pytest-cov +* pytest-xdist +* Optional: pytest-xvfb + +If you have `pytest<5` (used in python2), you may also want to install `pytest-faulthandler==1.6` plugin to output extra debugging information in case of test failures. This isn't necessary with `pytest>=5` + +### Tox + +As PyQtGraph supports a wide array of Qt-bindings, and python versions, we make use of `tox` to test against most of the configurations in our test matrix. As some of the qt-bindings are only installable via `conda`, `conda` needs to be in your `PATH`, and we utilize the `tox-conda` plugin. + +* Tests for a module should ideally cover all code in that module, i.e., statement coverage should be at 100%. +* To measure the test coverage, un `pytest --cov -n 4` to run the test suite with coverage on 4 cores. + +### Continous Integration + +For our Continuous Integration, we utilize Azure Pipelines. Tested configurations are visible on [README](README.md). More information on coverage and test failures can be found on the respective tabs of the [build results page](https://dev.azure.com/pyqtgraph/pyqtgraph/_build?definitionId=1) diff --git a/CONTRIBUTING.txt b/CONTRIBUTING.txt deleted file mode 100644 index 5a90495806..0000000000 --- a/CONTRIBUTING.txt +++ /dev/null @@ -1,60 +0,0 @@ -Contributions to pyqtgraph are welcome! - -Please use the following guidelines when preparing changes: - -* The preferred method for submitting changes is by github pull request - against the "develop" branch. If this is inconvenient, don't hesitate to - submit by other means. - -* Pull requests should include only a focused and related set of changes. - Mixed features and unrelated changes (such as .gitignore) will usually be - rejected. - -* For major changes, it is recommended to discuss your plans on the mailing - list or in a github issue before putting in too much effort. - - * Along these lines, please note that pyqtgraph.opengl will be deprecated - soon and replaced with VisPy. - -* Writing proper documentation and unit tests is highly encouraged. PyQtGraph - uses nose / py.test style testing, so tests should usually be included in a - tests/ directory adjacent to the relevant code. - -* Documentation is generated with sphinx; please check that docstring changes - compile correctly. - -* Style guidelines: - - * PyQtGraph prefers PEP8 for most style issues, but this is not enforced - rigorously as long as the code is clean and readable. - - * Use `python setup.py style` to see whether your code follows - the mandatory style guidelines checked by flake8. - - * Exception 1: All variable names should use camelCase rather than - underscore_separation. This is done for consistency with Qt - - * Exception 2: Function docstrings use ReStructuredText tables for - describing arguments: - - ``` - ============== ======================================================== - **Arguments:** - argName1 (type) Description of argument - argName2 (type) Description of argument. Longer descriptions must - be wrapped within the column guidelines defined by the - "====" header and footer. - ============== ======================================================== - ``` - - QObject subclasses that implement new signals should also describe - these in a similar table. - -* Setting up a test environment. - - Tests for a module should ideally cover all code in that module, - i.e., statement coverage should be at 100%. - - To measure the test coverage, install py.test, pytest-cov and pytest-xdist. - Then run 'py.test --cov -n 4' to run the test suite with coverage on 4 cores. - diff --git a/README.md b/README.md index 30268796cb..0778766337 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,72 @@ -[![Build Status](https://travis-ci.org/pyqtgraph/pyqtgraph.svg?branch=develop)](https://travis-ci.org/pyqtgraph/pyqtgraph) -[![codecov.io](http://codecov.io/github/pyqtgraph/pyqtgraph/coverage.svg?branch=develop)](http://codecov.io/github/pyqtgraph/pyqtgraph?branch=develop) + +[![Build Status](https://pyqtgraph.visualstudio.com/pyqtgraph/_apis/build/status/pyqtgraph.pyqtgraph?branchName=develop)](https://pyqtgraph.visualstudio.com/pyqtgraph/_build/latest?definitionId=17&branchName=develop) +[![Documentation Status](https://readthedocs.org/projects/pyqtgraph/badge/?version=latest)](https://pyqtgraph.readthedocs.io/en/latest/?badge=latest) PyQtGraph ========= -A pure-Python graphics library for PyQt/PySide +A pure-Python graphics library for PyQt/PySide/PyQt5/PySide2 -Copyright 2012 Luke Campagnola, University of North Carolina at Chapel Hill +Copyright 2020 Luke Campagnola, University of North Carolina at Chapel Hill -Maintainer ----------- - - * Luke Campagnola +PyQtGraph is intended for use in mathematics / scientific / engineering applications. +Despite being written entirely in python, the library is fast due to its +heavy leverage of numpy for number crunching, Qt's GraphicsView framework for +2D display, and OpenGL for 3D display. -Contributors +Requirements ------------ - * Megan Kratz - * Paul Manis - * Ingo Breßler - * Christian Gavin - * Michael Cristopher Hogg - * Ulrich Leutner - * Felix Schill - * Guillaume Poulin - * Antony Lee - * Mattias Põldaru - * Thomas S. - * Fabio Zadrozny - * Mikhail Terekhov - * Pietro Zambelli - * Stefan Holzmann - * Nicholas TJ - * John David Reaver - * David Kaplan - * Martin Fitzpatrick - * Daniel Lidstrom - * Eric Dill - * Vincent LeSaux +* Python 2.7, or 3.x +* Required + * PyQt 4.8+, PySide, PyQt5, or PySide2 + * `numpy` +* Optional + * `scipy` for image processing + * `pyopengl` for 3D graphics + * `hdf5` for large hdf5 binary format support -Requirements ------------- +Qt Bindings Test Matrix +----------------------- - * PyQt 4.7+, PySide, or PyQt5 - * python 2.6, 2.7, or 3.x - * NumPy - * For 3D graphics: pyopengl and qt-opengl - * Known to run on Windows, Linux, and Mac. +The following table represents the python environments we test in our CI system. Our CI system uses Ubuntu 18.04, Windows Server 2019, and macOS 10.15 base images. + +| Qt-Bindings | Python 2.7 | Python 3.6 | Python 3.7 | Python 3.8 | +| :------------- | :----------------: | :----------------: | :----------------: | :----------------: | +| PyQt-4 | :white_check_mark: | :x: | :x: | :x: | +| PySide1 | :white_check_mark: | :x: | :x: | :x: | +| PyQt5-5.9 | :x: | :white_check_mark: | :x: | :x: | +| PySide2-5.13 | :x: | :x: | :white_check_mark: | :x: | +| PyQt5-Latest | :x: | :x: | :x: | :white_check_mark: | +| PySide2-Latest | :x: | :x: | :x: | :white_check_mark: | + +* pyqtgraph has had some incompatibilities with PySide2 versions 5.6-5.11, and we recommend you avoid those versions if possible +* on macOS with Python 2.7 and Qt4 bindings (PyQt4 or PySide) the openGL related visualizations do not work reliably Support ------- - - Post at the [mailing list / forum](https://groups.google.com/forum/?fromgroups#!forum/pyqtgraph) + +* Report issues on the [GitHub issue tracker](https://github.com/pyqtgraph/pyqtgraph/issues) +* Post questions to the [mailing list / forum](https://groups.google.com/forum/?fromgroups#!forum/pyqtgraph) or [StackOverflow](https://stackoverflow.com/questions/tagged/pyqtgraph) Installation Methods -------------------- - * To use with a specific project, simply copy the pyqtgraph subdirectory - anywhere that is importable from your project. PyQtGraph may also be - used as a git subtree by cloning the git-core repository from github. - * To install system-wide from source distribution: - `$ python setup.py install` - * For installation packages, see the website (pyqtgraph.org) - * On debian-like systems, pyqtgraph requires the following packages: - python-numpy, python-qt4 | python-pyside - For 3D support: python-opengl, python-qt4-gl | python-pyside.qtopengl +* From PyPI: + * Last released version: `pip install pyqtgraph` + * Latest development version: `pip install git+https://github.com/pyqtgraph/pyqtgraph@master` +* From conda + * Last released version: `conda install -c conda-forge pyqtgraph` +* To install system-wide from source distribution: `python setup.py install` +* Many linux package repositories have release versions. +* To use with a specific project, simply copy the pyqtgraph subdirectory + anywhere that is importable from your project. Documentation ------------- -There are many examples; run `python -m pyqtgraph.examples` for a menu. +The official documentation lives at https://pyqtgraph.readthedocs.io -Some (incomplete) documentation exists at this time. - * Easiest place to get documentation is at - * If you acquired this code as a .tar.gz file from the website, then you can also look in - doc/html. - * If you acquired this code via GitHub, then you can build the documentation using sphinx. - From the documentation directory, run: - `$ make html` - -Please feel free to pester Luke or post to the forum if you need a specific - section of documentation to be expanded. +The easiest way to learn pyqtgraph is to browse through the examples; run `python -m pyqtgraph.examples` to launch the examples application. diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000000..eb3791191b --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,99 @@ +trigger: + branches: + include: + - '*' # Build for all branches if they have a azure-pipelines.yml file. + tags: + include: + - 'v*' # Ensure that we are building for tags starting with 'v' (Official Versions) + +# Build only for PRs for master branch +pr: + autoCancel: true + branches: + include: + - master + - develop + +variables: + OFFICIAL_REPO: 'pyqtgraph/pyqtgraph' + DEFAULT_MERGE_BRANCH: 'develop' + disable.coverage.autogenerate: 'true' + +stages: +- stage: "pre_test" + jobs: + - job: check_diff_size + pool: + vmImage: 'Ubuntu 18.04' + steps: + - bash: | + git config --global advice.detachedHead false + mkdir ~/repo-clone && cd ~/repo-clone + git init + + git remote add -t $(Build.SourceBranchName) origin $(Build.Repository.Uri) + git remote add -t ${DEFAULT_MERGE_BRANCH} upstream https://github.com/${OFFICIAL_REPO}.git + + git fetch origin $(Build.SourceBranchName) + git fetch upstream ${DEFAULT_MERGE_BRANCH} + + git checkout $(Build.SourceBranchName) + MERGE_SIZE=`du -s . | sed -e "s/\t.*//"` + echo -e "Merge Size ${MERGE_SIZE}" + + git checkout ${DEFAULT_MERGE_BRANCH} + TARGET_SIZE=`du -s . | sed -e "s/\t.*//"` + echo -e "Target Size ${TARGET_SIZE}" + + if [ "${MERGE_SIZE}" != "${TARGET_SIZE}" ]; then + SIZE_DIFF=`expr \( ${MERGE_SIZE} - ${TARGET_SIZE} \)`; + else + SIZE_DIFF=0; + fi; + echo -e "Estimated content size difference = ${SIZE_DIFF} kB" && + test ${SIZE_DIFF} -lt 100; + displayName: 'Diff Size Check' + continueOnError: true + + - job: "style_check" + pool: + vmImage: "Ubuntu 18.04" + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: 3.7 + - bash: | + pip install flake8 + python setup.py style + displayName: 'flake8 check' + continueOnError: true + + - job: "build_wheel" + pool: + vmImage: 'Ubuntu 18.04' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: 3.8 + - script: | + python -m pip install setuptools wheel + python setup.py bdist_wheel --universal + displayName: "Build Python Wheel" + continueOnError: false + - publish: dist + artifact: wheel + +- stage: "test" + jobs: + - template: azure-test-template.yml + parameters: + name: linux + vmImage: 'Ubuntu 18.04' + - template: azure-test-template.yml + parameters: + name: windows + vmImage: 'windows-2019' + - template: azure-test-template.yml + parameters: + name: macOS + vmImage: 'macOS-10.15' diff --git a/azure-test-template.yml b/azure-test-template.yml new file mode 100644 index 0000000000..902077a639 --- /dev/null +++ b/azure-test-template.yml @@ -0,0 +1,203 @@ +# Azure Pipelines CI job template for PyDM Tests +# https://docs.microsoft.com/en-us/azure/devops/pipelines/languages/anaconda?view=azure-devops +parameters: + name: '' + vmImage: '' + +jobs: +- job: ${{ parameters.name }} + pool: + vmImage: ${{ parameters.vmImage }} + strategy: + matrix: + Python27-PyQt4-4.8: + python.version: '2.7' + qt.bindings: "pyqt=4" + install.method: "conda" + Python27-PySide-4.8: + python.version: '2.7' + qt.bindings: "pyside" + install.method: "conda" + Python36-PyQt5-5.9: + python.version: "3.6" + qt.bindings: "pyqt" + install.method: "conda" + Python37-PySide2-5.13: + python.version: "3.7" + qt.bindings: "pyside2" + install.method: "conda" + Python38-PyQt5-Latest: + python.version: '3.8' + qt.bindings: "PyQt5" + install.method: "pip" + Python38-PySide2-Latest: + python.version: '3.8' + qt.bindings: "PySide2" + install.method: "pip" + + steps: + - task: DownloadPipelineArtifact@2 + inputs: + source: 'current' + artifact: wheel + path: 'dist' + + - task: ScreenResolutionUtility@1 + inputs: + displaySettings: 'specific' + width: '1920' + height: '1080' + condition: eq(variables['agent.os'], 'Windows_NT' ) + + - task: UsePythonVersion@0 + inputs: + versionSpec: $(python.version) + condition: eq(variables['install.method'], 'pip') + + - script: | + curl -LJO https://github.com/pal1000/mesa-dist-win/releases/download/19.1.0/mesa3d-19.1.0-release-msvc.exe + 7z x mesa3d-19.1.0-release-msvc.exe + cd x64 + xcopy opengl32.dll C:\windows\system32\mesadrv.dll* + xcopy opengl32.dll C:\windows\syswow64\mesadrv.dll* + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v DLL /t REG_SZ /d "mesadrv.dll" /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v DriverVersion /t REG_DWORD /d 1 /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v Flags /t REG_DWORD /d 1 /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v Version /t REG_DWORD /d 2 /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v DLL /t REG_SZ /d "mesadrv.dll" /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v DriverVersion /t REG_DWORD /d 1 /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v Flags /t REG_DWORD /d 1 /f + REG ADD "HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\Windows NT\CurrentVersion\OpenGLDrivers\MSOGL" /v Version /t REG_DWORD /d 2 /f + displayName: "Install Windows-Mesa OpenGL DLL" + condition: eq(variables['agent.os'], 'Windows_NT') + + - bash: | + if [ $(agent.os) == 'Linux' ] + then + echo "##vso[task.prependpath]$CONDA/bin" + elif [ $(agent.os) == 'Darwin' ] + then + sudo chown -R $USER $CONDA + echo "##vso[task.prependpath]$CONDA/bin" + elif [ $(agent.os) == 'Windows_NT' ] + then + echo "##vso[task.prependpath]$CONDA/Scripts" + else + echo 'Just what OS are you using?' + fi + displayName: 'Add Conda To $PATH' + condition: eq(variables['install.method'], 'conda' ) + continueOnError: false + + - bash: | + if [ $(install.method) == "conda" ] + then + conda update --all --yes --quiet + conda create --name test-environment-$(python.version) python=$(python.version) --yes --quiet + source activate test-environment-$(python.version) + conda config --env --set always_yes true + if [ $(python.version) == '2.7' ] + then + conda config --set restore_free_channel true + fi + if [ $(qt.bindings) == "pyside2" ] || ([ $(qt.bindings) == 'pyside' ] && [ $(agent.os) == 'Darwin' ]) + then + conda config --prepend channels conda-forge + fi + conda info + conda install $(qt.bindings) numpy scipy pyopengl h5py six --yes --quiet + else + pip install $(qt.bindings) numpy scipy pyopengl h5py six + fi + pip install pytest pytest-cov coverage pytest-xdist + if [ $(python.version) == "2.7" ] + then + pip install pytest-faulthandler==1.6.0 + export PYTEST_ADDOPTS="--faulthandler-timeout=15" + else + pip install pytest pytest-cov coverage + fi + displayName: "Install Dependencies" + + - bash: | + if [ $(install.method) == "conda" ] + then + source activate test-environment-$(python.version) + fi + python -m pip install --no-index --find-links=dist pyqtgraph + displayName: 'Install Wheel' + + - bash: | + sudo apt-get install -y libxkbcommon-x11-dev + # workaround for QTBUG-84489 + sudo apt-get install -y libxcb-xfixes0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 + if [ $(install.method) == "conda" ] + then + source activate test-environment-$(python.version) + fi + pip install PyVirtualDisplay==0.2.5 pytest-xvfb + displayName: "Virtual Display Setup" + condition: eq(variables['agent.os'], 'Linux' ) + + - bash: | + export QT_DEBUG_PLUGINS=1 + if [ $(install.method) == "conda" ] + then + source activate test-environment-$(python.version) + fi + echo python location: `which python` + echo python version: `python --version` + echo pytest location: `which pytest` + echo installed packages + pip list + echo pyqtgraph system info + python -c "import pyqtgraph as pg; pg.systemInfo()" + echo display information + if [ $(agent.os) == 'Linux' ] + then + export DISPLAY=:99.0 + Xvfb :99 -screen 0 1920x1200x24 -ac +extension GLX +render -noreset & + sleep 3 + fi + python -m pyqtgraph.util.get_resolution + echo openGL information + python -c "from pyqtgraph.opengl.glInfo import GLTest" + displayName: 'Debug Info' + continueOnError: false + + - bash: | + if [ $(install.method) == "conda" ] + then + source activate test-environment-$(python.version) + fi + mkdir -p "$SCREENSHOT_DIR" + # echo "If Screenshots are generated, they may be downloaded from:" + # echo "https://dev.azure.com/pyqtgraph/pyqtgraph/_apis/build/builds/$(Build.BuildId)/artifacts?artifactName=Screenshots&api-version=5.0" + pytest . -v \ + -n 1 \ + --junitxml=junit/test-results.xml \ + --cov pyqtgraph --cov-report=xml --cov-report=html + displayName: 'Unit tests' + env: + AZURE: 1 + SCREENSHOT_DIR: $(Build.ArtifactStagingDirectory)/screenshots + + - task: PublishBuildArtifacts@1 + displayName: 'Publish Screenshots' + condition: failed() + inputs: + pathtoPublish: $(Build.ArtifactStagingDirectory)/screenshots + artifactName: Screenshots + + - task: PublishTestResults@2 + condition: succeededOrFailed() + inputs: + testResultsFiles: '**/test-*.xml' + testRunTitle: 'Test Results for $(agent.os) - $(python.version) - $(qt.bindings) - $(install.method)' + publishRunAttachments: true + + - task: PublishCodeCoverageResults@1 + inputs: + codeCoverageTool: Cobertura + summaryFileLocation: '$(System.DefaultWorkingDirectory)/**/coverage.xml' + reportDirectory: '$(System.DefaultWorkingDirectory)/**/htmlcov' diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000000..60d1d1e701 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,5 @@ +pyside2 +numpy +pyopengl +sphinx +sphinx_rtd_theme diff --git a/doc/source/_static/custom.css b/doc/source/_static/custom.css new file mode 100644 index 0000000000..2ad3413b76 --- /dev/null +++ b/doc/source/_static/custom.css @@ -0,0 +1,14 @@ +/* Customizations to the theme */ + +/* override table width restrictions */ +/* https://github.com/readthedocs/sphinx_rtd_theme/issues/117 */ +@media screen and (min-width: 768px) { + .wy-table-responsive table td, .wy-table-responsive table th { + white-space: normal !important; + } + + .wy-table-responsive { + overflow: visible !important; + max-width: 100%; + } +} diff --git a/doc/source/apireference.rst b/doc/source/apireference.rst index c4dc64aafc..c52c8df14f 100644 --- a/doc/source/apireference.rst +++ b/doc/source/apireference.rst @@ -13,5 +13,7 @@ Contents: 3dgraphics/index colormap parametertree/index + dockarea graphicsscene/index flowchart/index + graphicswindow diff --git a/doc/source/conf.py b/doc/source/conf.py index 3ec48f75ab..04a95afd5f 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -11,7 +11,9 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import sys +import os +from datetime import datetime # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -19,6 +21,7 @@ path = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(path, '..', '..')) sys.path.insert(0, os.path.join(path, '..', 'extensions')) +import pyqtgraph # -- General configuration ----------------------------------------------------- @@ -43,16 +46,16 @@ # General information about the project. project = 'pyqtgraph' -copyright = '2011, Luke Campagnola' +copyright = '2011 - {}, Luke Campagnola'.format(datetime.now().year) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.10.0' +version = pyqtgraph.__version__ # The full version, including alpha/beta/rc tags. -release = '0.10.0' +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -88,12 +91,18 @@ # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] +autodoc_inherit_docstrings = False +autodoc_mock_imports = [ + "scipy", + "h5py", + "matplotlib", +] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -124,6 +133,10 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +# add the theme customizations +def setup(app): + app.add_stylesheet("custom.css") + # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' @@ -216,4 +229,3 @@ ('index', 'pyqtgraph', 'pyqtgraph Documentation', ['Luke Campagnola'], 1) ] - diff --git a/doc/source/dockarea.rst b/doc/source/dockarea.rst new file mode 100644 index 0000000000..384581d336 --- /dev/null +++ b/doc/source/dockarea.rst @@ -0,0 +1,11 @@ +Dock Area Module +================ + +.. automodule:: pyqtgraph.dockarea + :members: + +.. autoclass:: pyqtgraph.dockarea.DockArea + :members: + +.. autoclass:: pyqtgraph.dockarea.Dock + :members: diff --git a/doc/source/exporting.rst b/doc/source/exporting.rst index ccd017d707..0bb1c82af1 100644 --- a/doc/source/exporting.rst +++ b/doc/source/exporting.rst @@ -30,8 +30,13 @@ Export Formats for export. * Printer - Exports to the operating system's printing service. This exporter is provided for completeness, but is not well supported due to problems with Qt's printing system. +* HDF5 - Exports data from a :class:`~pyqtgraph.PlotItem` to a HDF5 file if + h5py_ is installed. This exporter supports :class:`~pyqtgraph.PlotItem` + objects containing multiple curves, stacking the data into a single HDF5 + dataset based on the ``columnMode`` parameter. If data items aren't the same + size, each one is given its own dataset. - +.. _h5py: https://www.h5py.org/ Exporting from the API ---------------------- diff --git a/doc/source/graphicsItems/bargraphitem.rst b/doc/source/graphicsItems/bargraphitem.rst new file mode 100644 index 0000000000..4959385b59 --- /dev/null +++ b/doc/source/graphicsItems/bargraphitem.rst @@ -0,0 +1,8 @@ +BarGraphItem +============ + +.. autoclass:: pyqtgraph.BarGraphItem + :members: + + .. automethod:: pyqtgraph.BarGraphItem.__init__ + diff --git a/doc/source/graphicsItems/dateaxisitem.rst b/doc/source/graphicsItems/dateaxisitem.rst new file mode 100644 index 0000000000..9da36c6f43 --- /dev/null +++ b/doc/source/graphicsItems/dateaxisitem.rst @@ -0,0 +1,8 @@ +DateAxisItem +============ + +.. autoclass:: pyqtgraph.DateAxisItem + :members: + + .. automethod:: pyqtgraph.DateAxisItem.__init__ + diff --git a/doc/source/graphicsItems/index.rst b/doc/source/graphicsItems/index.rst index 7042d27ee7..390d8f1771 100644 --- a/doc/source/graphicsItems/index.rst +++ b/doc/source/graphicsItems/index.rst @@ -24,6 +24,7 @@ Contents: axisitem textitem errorbaritem + bargraphitem arrowitem fillbetweenitem curvepoint @@ -42,4 +43,4 @@ Contents: graphicsitem uigraphicsitem graphicswidgetanchor - + dateaxisitem diff --git a/doc/source/graphicsItems/make b/doc/source/graphicsItems/make index 293db0d6b5..9d9f995431 100644 --- a/doc/source/graphicsItems/make +++ b/doc/source/graphicsItems/make @@ -2,6 +2,7 @@ files = """ArrowItem AxisItem ButtonItem CurvePoint +DateAxisItem GradientEditorItem GradientLegend GraphicsLayout diff --git a/doc/source/graphicswindow.rst b/doc/source/graphicswindow.rst index 3d5641c3d5..0602ae7ed2 100644 --- a/doc/source/graphicswindow.rst +++ b/doc/source/graphicswindow.rst @@ -1,8 +1,16 @@ -Basic display widgets -===================== +Deprecated Window Classes +========================= - - GraphicsWindow - - GraphicsView - - GraphicsLayoutItem - - ViewBox +.. automodule:: pyqtgraph.graphicsWindows +.. autoclass:: pyqtgraph.GraphicsWindow + :members: + +.. autoclass:: pyqtgraph.TabWindow + :members: + +.. autoclass:: pyqtgraph.PlotWindow + :members: + +.. autoclass:: pyqtgraph.ImageWindow + :members: diff --git a/doc/source/installation.rst b/doc/source/installation.rst index e2bf0f8d46..fd9f5288ee 100644 --- a/doc/source/installation.rst +++ b/doc/source/installation.rst @@ -1,9 +1,70 @@ Installation ============ -PyQtGraph does not really require any installation scripts. All that is needed is for the pyqtgraph folder to be placed someplace importable. Most people will prefer to simply place this folder within a larger project folder. If you want to make pyqtgraph available system-wide, use one of the methods listed below: +PyQtGraph depends on: -* **Debian, Ubuntu, and similar Linux:** Download the .deb file linked at the top of the pyqtgraph web page or install using apt by putting "deb http://luke.campagnola.me/debian dev/" in your /etc/apt/sources.list file and install the python-pyqtgraph package. -* **Arch Linux:** Looks like someone has posted unofficial packages for Arch (thanks windel). (https://aur.archlinux.org/packages.php?ID=62577) -* **Windows:** Download and run the .exe installer file linked at the top of the pyqtgraph web page. -* **Everybody (including OSX):** Download the .tar.gz source package linked at the top of the pyqtgraph web page, extract its contents, and run "python setup.py install" from within the extracted directory. +* Python 2.7 or Python 3.x +* A Qt library such as PyQt4, PyQt5, PySide, or PySide2 +* numpy + +The easiest way to meet these dependencies is with ``pip`` or with a scientific +python distribution like Anaconda. + +There are many different ways to install pyqtgraph, depending on your needs: + +pip +--- + +The most common way to install pyqtgraph is with pip:: + + $ pip install pyqtgraph + +Some users may need to call ``pip3`` instead. This method should work on all +platforms. + +conda +----- + +pyqtgraph is on the default Anaconda channel:: + + $ conda install pyqtgraph + +It is also available in the conda-forge channel:: + + $ conda install -c conda-forge pyqtgraph + +From Source +----------- + +To get access to the very latest features and bugfixes you have three choices: + +1. Clone pyqtgraph from github:: + + $ git clone https://github.com/pyqtgraph/pyqtgraph + $ cd pyqtgraph + + Now you can install pyqtgraph from the source:: + + $ pip install . + +2. Directly install from GitHub repo:: + + $ pip install git+git://github.com/pyqtgraph/pyqtgraph.git@develop + + You can change ``develop`` of the above command to the branch name or the + commit you prefer. + +3. You can simply place the pyqtgraph folder someplace importable, such as + inside the root of another project. PyQtGraph does not need to be "built" or + compiled in any way. + +Other Packages +-------------- + +Packages for pyqtgraph are also available in a few other forms: + +* **Debian, Ubuntu, and similar Linux:** Use ``apt install python-pyqtgraph`` or + download the .deb file linked at the top of the pyqtgraph web page. +* **Arch Linux:** https://www.archlinux.org/packages/community/any/python-pyqtgraph/ +* **Windows:** Download and run the .exe installer file linked at the top of the + pyqtgraph web page: http://pyqtgraph.org diff --git a/doc/source/introduction.rst b/doc/source/introduction.rst index 7016117398..741acd3051 100644 --- a/doc/source/introduction.rst +++ b/doc/source/introduction.rst @@ -73,7 +73,7 @@ How does it compare to... such as image interaction, volumetric rendering, parameter trees, flowcharts, etc. -* pyqwt5: About as fast as pyqwt5, but not quite as complete for plotting +* pyqwt5: About as fast as pyqtgraph, but not quite as complete for plotting functionality. Image handling in pyqtgraph is much more complete (again, no ROI widgets in qwt). Also, pyqtgraph is written in pure python, so it is more portable than pyqwt, which often lags behind pyqt in development (I diff --git a/doc/source/mouse_interaction.rst b/doc/source/mouse_interaction.rst index 71a764152c..c1bec45d72 100644 --- a/doc/source/mouse_interaction.rst +++ b/doc/source/mouse_interaction.rst @@ -9,11 +9,11 @@ Most applications that use pyqtgraph's data visualization will generate widgets In pyqtgraph, most 2D visualizations follow the following mouse interaction: -* Left button: Interacts with items in the scene (select/move objects, etc). If there are no movable objects under the mouse cursor, then dragging with the left button will pan the scene instead. -* Right button drag: Scales the scene. Dragging left/right scales horizontally; dragging up/down scales vertically (although some scenes will have their x/y scales locked together). If there are x/y axes visible in the scene, then right-dragging over the axis will _only_ affect that axis. -* Right button click: Clicking the right button in most cases will show a context menu with a variety of options depending on the object(s) under the mouse cursor. -* Middle button (or wheel) drag: Dragging the mouse with the wheel pressed down will always pan the scene (this is useful in instances where panning with the left button is prevented by other objects in the scene). -* Wheel spin: Zooms the scene in and out. +* **Left button:** Interacts with items in the scene (select/move objects, etc). If there are no movable objects under the mouse cursor, then dragging with the left button will pan the scene instead. +* **Right button drag:** Scales the scene. Dragging left/right scales horizontally; dragging up/down scales vertically (although some scenes will have their x/y scales locked together). If there are x/y axes visible in the scene, then right-dragging over the axis will _only_ affect that axis. +* **Right button click:** Clicking the right button in most cases will show a context menu with a variety of options depending on the object(s) under the mouse cursor. +* **Middle button (or wheel) drag:** Dragging the mouse with the wheel pressed down will always pan the scene (this is useful in instances where panning with the left button is prevented by other objects in the scene). +* **Wheel spin:** Zooms the scene in and out. For machines where dragging with the right or middle buttons is difficult (usually Mac), another mouse interaction mode exists. In this mode, dragging with the left mouse button draws a box over a region of the scene. After the button is released, the scene is scaled and panned to fit the box. This mode can be accessed in the context menu or by calling:: @@ -38,11 +38,11 @@ The exact set of items available in the menu depends on the contents of the scen 3D visualizations use the following mouse interaction: -* Left button drag: Rotates the scene around a central point -* Middle button drag: Pan the scene by moving the central "look-at" point within the x-y plane -* Middle button drag + CTRL: Pan the scene by moving the central "look-at" point along the z axis -* Wheel spin: zoom in/out -* Wheel + CTRL: change field-of-view angle +* **Left button drag:** Rotates the scene around a central point +* **Middle button drag:** Pan the scene by moving the central "look-at" point within the x-y plane +* **Middle button drag + CTRL:** Pan the scene by moving the central "look-at" point along the z axis +* **Wheel spin:** zoom in/out +* **Wheel + CTRL:** change field-of-view angle And keyboard controls: diff --git a/doc/source/plotting.rst b/doc/source/plotting.rst index 8a99663afc..956f5b97ee 100644 --- a/doc/source/plotting.rst +++ b/doc/source/plotting.rst @@ -41,7 +41,7 @@ There are several classes invloved in displaying plot data. Most of these classe * :class:`AxisItem ` - Displays axis values, ticks, and labels. Most commonly used with PlotItem. * Container Classes (subclasses of QWidget; may be embedded in PyQt GUIs) * :class:`PlotWidget ` - A subclass of GraphicsView with a single PlotItem displayed. Most of the methods provided by PlotItem are also available through PlotWidget. - * :class:`GraphicsLayoutWidget ` - QWidget subclass displaying a single GraphicsLayoutItem. Most of the methods provided by GraphicsLayoutItem are also available through GraphicsLayoutWidget. + * :class:`GraphicsLayoutWidget ` - QWidget subclass displaying a single :class:`~pyqtgraph.GraphicsLayout`. Most of the methods provided by :class:`~pyqtgraph.GraphicsLayout` are also available through GraphicsLayoutWidget. .. image:: images/plottingClasses.png @@ -69,5 +69,3 @@ Create/show a plot widget, display three data curves:: for i in range(3): plotWidget.plot(x, y[i], pen=(i,3)) ## setting pen=(i,3) automaticaly creates three different-colored pens - - diff --git a/doc/source/widgets/dockarea.rst b/doc/source/widgets/dockarea.rst deleted file mode 100644 index 09a6accaf4..0000000000 --- a/doc/source/widgets/dockarea.rst +++ /dev/null @@ -1,5 +0,0 @@ -dockarea module -=============== - -.. automodule:: pyqtgraph.dockarea - :members: diff --git a/doc/source/widgets/index.rst b/doc/source/widgets/index.rst index 9cfbc0c471..e5acb7f05d 100644 --- a/doc/source/widgets/index.rst +++ b/doc/source/widgets/index.rst @@ -12,11 +12,9 @@ Contents: plotwidget imageview - dockarea spinbox gradientwidget histogramlutwidget - parametertree consolewidget colormapwidget scatterplotwidget diff --git a/doc/source/widgets/parametertree.rst b/doc/source/widgets/parametertree.rst deleted file mode 100644 index 565b930b10..0000000000 --- a/doc/source/widgets/parametertree.rst +++ /dev/null @@ -1,5 +0,0 @@ -parametertree module -==================== - -.. automodule:: pyqtgraph.parametertree - :members: diff --git a/examples/CustomGraphItem.py b/examples/CustomGraphItem.py index 695768e2de..8e494c3a8f 100644 --- a/examples/CustomGraphItem.py +++ b/examples/CustomGraphItem.py @@ -12,7 +12,7 @@ # Enable antialiasing for prettier plots pg.setConfigOptions(antialias=True) -w = pg.GraphicsWindow() +w = pg.GraphicsLayoutWidget(show=True) w.setWindowTitle('pyqtgraph example: CustomGraphItem') v = w.addViewBox() v.setAspectLocked() diff --git a/examples/DataTreeWidget.py b/examples/DataTreeWidget.py index 8365db2a4e..70ac49bda2 100644 --- a/examples/DataTreeWidget.py +++ b/examples/DataTreeWidget.py @@ -11,15 +11,29 @@ import numpy as np +# for generating a traceback object to display +def some_func1(): + return some_func2() +def some_func2(): + try: + raise Exception() + except: + import sys + return sys.exc_info()[2] + + app = QtGui.QApplication([]) d = { - 'list1': [1,2,3,4,5,6, {'nested1': 'aaaaa', 'nested2': 'bbbbb'}, "seven"], - 'dict1': { + 'a list': [1,2,3,4,5,6, {'nested1': 'aaaaa', 'nested2': 'bbbbb'}, "seven"], + 'a dict': { 'x': 1, 'y': 2, 'z': 'three' }, - 'array1 (20x20)': np.ones((10,10)) + 'an array': np.random.randint(10, size=(40,10)), + 'a traceback': some_func1(), + 'a function': some_func1, + 'a class': pg.DataTreeWidget, } tree = pg.DataTreeWidget(data=d) diff --git a/examples/DateAxisItem.py b/examples/DateAxisItem.py new file mode 100644 index 0000000000..7bbaafffbb --- /dev/null +++ b/examples/DateAxisItem.py @@ -0,0 +1,33 @@ +""" +Demonstrates the usage of DateAxisItem to display properly-formatted +timestamps on x-axis which automatically adapt to current zoom level. + +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import time +from datetime import datetime, timedelta + +import numpy as np +import pyqtgraph as pg +from pyqtgraph.Qt import QtGui + +app = QtGui.QApplication([]) + +# Create a plot with a date-time axis +w = pg.PlotWidget(axisItems = {'bottom': pg.DateAxisItem()}) +w.showGrid(x=True, y=True) + +# Plot sin(1/x^2) with timestamps in the last 100 years +now = time.time() +x = np.linspace(2*np.pi, 1000*2*np.pi, 8301) +w.plot(now-(2*np.pi/x)**2*100*np.pi*1e7, np.sin(x), symbol='o') + +w.setWindowTitle('pyqtgraph example: DateAxisItem') +w.show() + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + app.exec_() diff --git a/examples/DateAxisItem_QtDesigner.py b/examples/DateAxisItem_QtDesigner.py new file mode 100644 index 0000000000..f6f17489c0 --- /dev/null +++ b/examples/DateAxisItem_QtDesigner.py @@ -0,0 +1,48 @@ +""" +Demonstrates the usage of DateAxisItem in a layout created with Qt Designer. + +The spotlight here is on the 'setAxisItems' method, without which +one would have to subclass plotWidget in order to attach a dateaxis to it. + +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import sys +import time + +import numpy as np +from PyQt5 import QtWidgets, QtCore, uic +import pyqtgraph as pg + +pg.setConfigOption('background', 'w') +pg.setConfigOption('foreground', 'k') + +BLUE = pg.mkPen('#1f77b4') + +Design, _ = uic.loadUiType('DateAxisItem_QtDesigner.ui') + +class ExampleApp(QtWidgets.QMainWindow, Design): + def __init__(self): + super().__init__() + self.setupUi(self) + now = time.time() + # Plot random values with timestamps in the last 6 months + timestamps = np.linspace(now - 6*30*24*3600, now, 100) + self.curve = self.plotWidget.plot(x=timestamps, y=np.random.rand(100), + symbol='o', symbolSize=5, pen=BLUE) + # 'o' circle 't' triangle 'd' diamond '+' plus 's' square + self.plotWidget.setAxisItems({'bottom': pg.DateAxisItem()}) + self.plotWidget.showGrid(x=True, y=True) + +app = QtWidgets.QApplication(sys.argv) +app.setStyle(QtWidgets.QStyleFactory.create('Fusion')) +app.setPalette(QtWidgets.QApplication.style().standardPalette()) +window = ExampleApp() +window.setWindowTitle('pyqtgraph example: DateAxisItem_QtDesigner') +window.show() + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + app.exec_() diff --git a/examples/DateAxisItem_QtDesigner.ui b/examples/DateAxisItem_QtDesigner.ui new file mode 100644 index 0000000000..91f77ba946 --- /dev/null +++ b/examples/DateAxisItem_QtDesigner.ui @@ -0,0 +1,44 @@ + + + MainWindow + + + + 0 + 0 + 536 + 381 + + + + MainWindow + + + + + + + + + + + + 0 + 0 + 536 + 18 + + + + + + + + PlotWidget + QGraphicsView +
pyqtgraph
+
+
+ + +
diff --git a/examples/DiffTreeWidget.py b/examples/DiffTreeWidget.py new file mode 100644 index 0000000000..fa57a356ab --- /dev/null +++ b/examples/DiffTreeWidget.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- + +""" +Simple use of DiffTreeWidget to display differences between structures of +nested dicts, lists, and arrays. +""" + +import initExample ## Add path to library (just for examples; you do not need this) + +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + + +app = QtGui.QApplication([]) +A = { + 'a list': [1,2,2,4,5,6, {'nested1': 'aaaa', 'nested2': 'bbbbb'}, "seven"], + 'a dict': { + 'x': 1, + 'y': 2, + 'z': 'three' + }, + 'an array': np.random.randint(10, size=(40,10)), + #'a traceback': some_func1(), + #'a function': some_func1, + #'a class': pg.DataTreeWidget, +} + +B = { + 'a list': [1,2,3,4,5,5, {'nested1': 'aaaaa', 'nested2': 'bbbbb'}, "seven"], + 'a dict': { + 'x': 2, + 'y': 2, + 'z': 'three', + 'w': 5 + }, + 'another dict': {1:2, 2:3, 3:4}, + 'an array': np.random.randint(10, size=(40,10)), +} + +tree = pg.DiffTreeWidget() +tree.setData(A, B) +tree.show() +tree.setWindowTitle('pyqtgraph example: DiffTreeWidget') +tree.resize(1000, 800) + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() \ No newline at end of file diff --git a/examples/GLImageItem.py b/examples/GLImageItem.py index 581474fd18..70bf530691 100644 --- a/examples/GLImageItem.py +++ b/examples/GLImageItem.py @@ -26,9 +26,9 @@ ## slice out three planes, convert to RGBA for OpenGL texture levels = (-0.08, 0.08) -tex1 = pg.makeRGBA(data[shape[0]/2], levels=levels)[0] # yz plane -tex2 = pg.makeRGBA(data[:,shape[1]/2], levels=levels)[0] # xz plane -tex3 = pg.makeRGBA(data[:,:,shape[2]/2], levels=levels)[0] # xy plane +tex1 = pg.makeRGBA(data[shape[0]//2], levels=levels)[0] # yz plane +tex2 = pg.makeRGBA(data[:,shape[1]//2], levels=levels)[0] # xz plane +tex3 = pg.makeRGBA(data[:,:,shape[2]//2], levels=levels)[0] # xy plane #tex1[:,:,3] = 128 #tex2[:,:,3] = 128 #tex3[:,:,3] = 128 diff --git a/examples/GraphItem.py b/examples/GraphItem.py index c6362295d0..094b84bd4f 100644 --- a/examples/GraphItem.py +++ b/examples/GraphItem.py @@ -13,7 +13,7 @@ # Enable antialiasing for prettier plots pg.setConfigOptions(antialias=True) -w = pg.GraphicsWindow() +w = pg.GraphicsLayoutWidget(show=True) w.setWindowTitle('pyqtgraph example: GraphItem') v = w.addViewBox() v.setAspectLocked() diff --git a/examples/HistogramLUT.py b/examples/HistogramLUT.py index 4d89dd3f8d..082a963cd8 100644 --- a/examples/HistogramLUT.py +++ b/examples/HistogramLUT.py @@ -28,19 +28,27 @@ vb = pg.ViewBox() vb.setAspectLocked() v.setCentralItem(vb) -l.addWidget(v, 0, 0) +l.addWidget(v, 0, 0, 3, 1) w = pg.HistogramLUTWidget() l.addWidget(w, 0, 1) -data = pg.gaussianFilter(np.random.normal(size=(256, 256)), (20, 20)) +monoRadio = QtGui.QRadioButton('mono') +rgbaRadio = QtGui.QRadioButton('rgba') +l.addWidget(monoRadio, 1, 1) +l.addWidget(rgbaRadio, 2, 1) +monoRadio.setChecked(True) + +def setLevelMode(): + mode = 'mono' if monoRadio.isChecked() else 'rgba' + w.setLevelMode(mode) +monoRadio.toggled.connect(setLevelMode) + +data = pg.gaussianFilter(np.random.normal(size=(256, 256, 3)), (20, 20, 0)) for i in range(32): for j in range(32): data[i*8, j*8] += .1 img = pg.ImageItem(data) -#data2 = np.zeros((2,) + data.shape + (2,)) -#data2[0,:,:,0] = data ## make non-contiguous array for testing purposes -#img = pg.ImageItem(data2[0,:,:,0]) vb.addItem(img) vb.autoRange() diff --git a/examples/InfiniteLine.py b/examples/InfiniteLine.py index 50efbd0498..55020776eb 100644 --- a/examples/InfiniteLine.py +++ b/examples/InfiniteLine.py @@ -10,7 +10,7 @@ app = QtGui.QApplication([]) -win = pg.GraphicsWindow(title="Plotting items examples") +win = pg.GraphicsLayoutWidget(show=True, title="Plotting items examples") win.resize(1000,600) # Enable antialiasing for prettier plots diff --git a/examples/LogPlotTest.py b/examples/LogPlotTest.py index d408a2b4f2..5ae9d17ecf 100644 --- a/examples/LogPlotTest.py +++ b/examples/LogPlotTest.py @@ -12,7 +12,7 @@ app = QtGui.QApplication([]) -win = pg.GraphicsWindow(title="Basic plotting examples") +win = pg.GraphicsLayoutWidget(show=True, title="Basic plotting examples") win.resize(1000,600) win.setWindowTitle('pyqtgraph example: LogPlotTest') diff --git a/examples/MultiPlotSpeedTest.py b/examples/MultiPlotSpeedTest.py index 0d0d701b56..f429568759 100644 --- a/examples/MultiPlotSpeedTest.py +++ b/examples/MultiPlotSpeedTest.py @@ -12,32 +12,27 @@ import numpy as np import pyqtgraph as pg from pyqtgraph.ptime import time -#QtGui.QApplication.setGraphicsSystem('raster') app = QtGui.QApplication([]) -#mw = QtGui.QMainWindow() -#mw.resize(800,800) -p = pg.plot() -p.setWindowTitle('pyqtgraph example: MultiPlotSpeedTest') -#p.setRange(QtCore.QRectF(0, -10, 5000, 20)) -p.setLabel('bottom', 'Index', units='B') +plot = pg.plot() +plot.setWindowTitle('pyqtgraph example: MultiPlotSpeedTest') +plot.setLabel('bottom', 'Index', units='B') nPlots = 100 nSamples = 500 -#curves = [p.plot(pen=(i,nPlots*1.3)) for i in range(nPlots)] curves = [] -for i in range(nPlots): - c = pg.PlotCurveItem(pen=(i,nPlots*1.3)) - p.addItem(c) - c.setPos(0,i*6) - curves.append(c) +for idx in range(nPlots): + curve = pg.PlotCurveItem(pen=(idx,nPlots*1.3)) + plot.addItem(curve) + curve.setPos(0,idx*6) + curves.append(curve) -p.setYRange(0, nPlots*6) -p.setXRange(0, nSamples) -p.resize(600,900) +plot.setYRange(0, nPlots*6) +plot.setXRange(0, nSamples) +plot.resize(600,900) rgn = pg.LinearRegionItem([nSamples/5.,nSamples/3.]) -p.addItem(rgn) +plot.addItem(rgn) data = np.random.normal(size=(nPlots*23,nSamples)) @@ -46,13 +41,12 @@ fps = None count = 0 def update(): - global curve, data, ptr, p, lastTime, fps, nPlots, count + global curve, data, ptr, plot, lastTime, fps, nPlots, count count += 1 - #print "---------", count + for i in range(nPlots): curves[i].setData(data[(ptr+i)%data.shape[0]]) - - #print " setData done." + ptr += nPlots now = time() dt = now - lastTime @@ -62,13 +56,11 @@ def update(): else: s = np.clip(dt*3., 0, 1) fps = fps * (1-s) + (1.0/dt) * s - p.setTitle('%0.2f fps' % fps) + plot.setTitle('%0.2f fps' % fps) #app.processEvents() ## force complete redraw for every plot timer = QtCore.QTimer() timer.timeout.connect(update) timer.start(0) - - ## Start Qt event loop unless running in interactive mode. if __name__ == '__main__': diff --git a/examples/MultiPlotWidget.py b/examples/MultiPlotWidget.py index 5ab4b21d4f..67cb83eed8 100644 --- a/examples/MultiPlotWidget.py +++ b/examples/MultiPlotWidget.py @@ -3,8 +3,7 @@ ## Add path to library (just for examples; you do not need this) import initExample - -from scipy import random +import numpy as np from numpy import linspace from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg @@ -22,7 +21,7 @@ mw.setCentralWidget(pw) mw.show() -data = random.normal(size=(3, 1000)) * np.array([[0.1], [1e-5], [1]]) +data = np.random.normal(size=(3, 1000)) * np.array([[0.1], [1e-5], [1]]) ma = MetaArray(data, info=[ {'name': 'Signal', 'cols': [ {'name': 'Col1', 'units': 'V'}, diff --git a/examples/PanningPlot.py b/examples/PanningPlot.py index 165240b2ae..874bf330b5 100644 --- a/examples/PanningPlot.py +++ b/examples/PanningPlot.py @@ -9,7 +9,7 @@ from pyqtgraph.Qt import QtCore, QtGui import numpy as np -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: PanningPlot') plt = win.addPlot() diff --git a/examples/PlotAutoRange.py b/examples/PlotAutoRange.py index 46aa3a4436..0e3cd42296 100644 --- a/examples/PlotAutoRange.py +++ b/examples/PlotAutoRange.py @@ -16,7 +16,7 @@ #mw = QtGui.QMainWindow() #mw.resize(800,800) -win = pg.GraphicsWindow(title="Plot auto-range examples") +win = pg.GraphicsLayoutWidget(show=True, title="Plot auto-range examples") win.resize(800,600) win.setWindowTitle('pyqtgraph example: PlotAutoRange') diff --git a/examples/PlotWidget.py b/examples/PlotWidget.py index e52a893dd8..38bbc73cd4 100644 --- a/examples/PlotWidget.py +++ b/examples/PlotWidget.py @@ -13,7 +13,7 @@ import pyqtgraph as pg #QtGui.QApplication.setGraphicsSystem('raster') -app = QtGui.QApplication([]) +app = pg.mkQApp() mw = QtGui.QMainWindow() mw.setWindowTitle('pyqtgraph example: PlotWidget') mw.resize(800,800) diff --git a/examples/Plotting.py b/examples/Plotting.py index 44996ae5b8..130698a494 100644 --- a/examples/Plotting.py +++ b/examples/Plotting.py @@ -17,7 +17,7 @@ #mw = QtGui.QMainWindow() #mw.resize(800,800) -win = pg.GraphicsWindow(title="Basic plotting examples") +win = pg.GraphicsLayoutWidget(show=True, title="Basic plotting examples") win.resize(1000,600) win.setWindowTitle('pyqtgraph example: Plotting') diff --git a/examples/ProgressDialog.py b/examples/ProgressDialog.py new file mode 100644 index 0000000000..141d2bb46e --- /dev/null +++ b/examples/ProgressDialog.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +""" +Using ProgressDialog to show progress updates in a nested process. + +""" +import initExample ## Add path to library (just for examples; you do not need this) + +import time +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui + +app = QtGui.QApplication([]) + + +def runStage(i): + """Waste time for 2 seconds while incrementing a progress bar. + """ + with pg.ProgressDialog("Running stage %s.." % i, maximum=100, nested=True) as dlg: + for j in range(100): + time.sleep(0.02) + dlg += 1 + if dlg.wasCanceled(): + print("Canceled stage %s" % i) + break + + +def runManyStages(i): + """Iterate over runStage() 3 times while incrementing a progress bar. + """ + with pg.ProgressDialog("Running stage %s.." % i, maximum=3, nested=True, wait=0) as dlg: + for j in range(1,4): + runStage('%d.%d' % (i, j)) + dlg += 1 + if dlg.wasCanceled(): + print("Canceled stage %s" % i) + break + + +with pg.ProgressDialog("Doing a multi-stage process..", maximum=5, nested=True, wait=0) as dlg1: + for i in range(1,6): + if i == 3: + # this stage will have 3 nested progress bars + runManyStages(i) + else: + # this stage will have 2 nested progress bars + runStage(i) + + dlg1 += 1 + if dlg1.wasCanceled(): + print("Canceled process") + break + + diff --git a/examples/ROIExamples.py b/examples/ROIExamples.py index a48fa7b55c..fe3e4db8b7 100644 --- a/examples/ROIExamples.py +++ b/examples/ROIExamples.py @@ -33,7 +33,7 @@ ## create GUI app = QtGui.QApplication([]) -w = pg.GraphicsWindow(size=(1000,800), border=True) +w = pg.GraphicsLayoutWidget(show=True, size=(1000,800), border=True) w.setWindowTitle('pyqtgraph example: ROI Examples') text = """Data Selection From Image.
\n @@ -138,7 +138,7 @@ def update(roi): v4 = w4.addViewBox(row=1, col=0, lockAspect=True) g = pg.GridItem() v4.addItem(g) -r4 = pg.ROI([0,0], [100,100], removable=True) +r4 = pg.ROI([0,0], [100,100], resizable=False, removable=True) r4.addRotateHandle([1,0], [0.5, 0.5]) r4.addRotateHandle([0,1], [0.5, 0.5]) img4 = pg.ImageItem(arr) diff --git a/examples/ROItypes.py b/examples/ROItypes.py index 9e67ebe14c..4352f8885c 100644 --- a/examples/ROItypes.py +++ b/examples/ROItypes.py @@ -13,7 +13,7 @@ ## create GUI app = QtGui.QApplication([]) -w = pg.GraphicsWindow(size=(800,800), border=True) +w = pg.GraphicsLayoutWidget(show=True, size=(800,800), border=True) v = w.addViewBox(colspan=2) v.invertY(True) ## Images usually have their Y-axis pointing downward v.setAspectLocked(True) @@ -92,10 +92,10 @@ def updateRoiPlot(roi, data=None): rois = [] rois.append(pg.TestROI([0, 0], [20, 20], maxBounds=QtCore.QRectF(-10, -10, 230, 140), pen=(0,9))) rois.append(pg.LineROI([0, 0], [20, 20], width=5, pen=(1,9))) -rois.append(pg.MultiLineROI([[0, 50], [50, 60], [60, 30]], width=5, pen=(2,9))) +rois.append(pg.MultiRectROI([[0, 50], [50, 60], [60, 30]], width=5, pen=(2,9))) rois.append(pg.EllipseROI([110, 10], [30, 20], pen=(3,9))) rois.append(pg.CircleROI([110, 50], [20, 20], pen=(4,9))) -rois.append(pg.PolygonROI([[2,0], [2.1,0], [2,.1]], pen=(5,9))) +rois.append(pg.PolyLineROI([[2,0], [2.1,0], [2,.1]], pen=(5,9))) #rois.append(SpiralROI([20,30], [1,1], pen=mkPen(0))) ## Add each ROI to the scene and link its data to a plot curve with the same color diff --git a/examples/ScaleBar.py b/examples/ScaleBar.py index 5f9675e4fb..f125eb7395 100644 --- a/examples/ScaleBar.py +++ b/examples/ScaleBar.py @@ -9,7 +9,7 @@ import numpy as np pg.mkQApp() -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: ScaleBar') vb = win.addViewBox() diff --git a/examples/ScatterPlot.py b/examples/ScatterPlot.py index 72022acc5a..93f184f2b9 100644 --- a/examples/ScatterPlot.py +++ b/examples/ScatterPlot.py @@ -11,6 +11,7 @@ from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg import numpy as np +from collections import namedtuple app = QtGui.QApplication([]) mw = QtGui.QMainWindow() @@ -32,8 +33,8 @@ ## There are a few different ways we can draw scatter plots; each is optimized for different types of data: -## 1) All spots identical and transform-invariant (top-left plot). -## In this case we can get a huge performance boost by pre-rendering the spot +## 1) All spots identical and transform-invariant (top-left plot). +## In this case we can get a huge performance boost by pre-rendering the spot ## image and just drawing that image repeatedly. n = 300 @@ -57,21 +58,41 @@ def clicked(plot, points): -## 2) Spots are transform-invariant, but not identical (top-right plot). -## In this case, drawing is almsot as fast as 1), but there is more startup -## overhead and memory usage since each spot generates its own pre-rendered +## 2) Spots are transform-invariant, but not identical (top-right plot). +## In this case, drawing is almsot as fast as 1), but there is more startup +## overhead and memory usage since each spot generates its own pre-rendered ## image. +TextSymbol = namedtuple("TextSymbol", "label symbol scale") + +def createLabel(label, angle): + symbol = QtGui.QPainterPath() + #symbol.addText(0, 0, QFont("San Serif", 10), label) + f = QtGui.QFont() + f.setPointSize(10) + symbol.addText(0, 0, f, label) + br = symbol.boundingRect() + scale = min(1. / br.width(), 1. / br.height()) + tr = QtGui.QTransform() + tr.scale(scale, scale) + tr.rotate(angle) + tr.translate(-br.x() - br.width()/2., -br.y() - br.height()/2.) + return TextSymbol(label, tr.map(symbol), 0.1 / scale) + +random_str = lambda : (''.join([chr(np.random.randint(ord('A'),ord('z'))) for i in range(np.random.randint(1,5))]), np.random.randint(0, 360)) + s2 = pg.ScatterPlotItem(size=10, pen=pg.mkPen('w'), pxMode=True) pos = np.random.normal(size=(2,n), scale=1e-5) spots = [{'pos': pos[:,i], 'data': 1, 'brush':pg.intColor(i, n), 'symbol': i%5, 'size': 5+i/10.} for i in range(n)] s2.addPoints(spots) +spots = [{'pos': pos[:,i], 'data': 1, 'brush':pg.intColor(i, n), 'symbol': label[1], 'size': label[2]*(5+i/10.)} for (i, label) in [(i, createLabel(*random_str())) for i in range(n)]] +s2.addPoints(spots) w2.addItem(s2) s2.sigClicked.connect(clicked) -## 3) Spots are not transform-invariant, not identical (bottom-left). -## This is the slowest case, since all spots must be completely re-drawn +## 3) Spots are not transform-invariant, not identical (bottom-left). +## This is the slowest case, since all spots must be completely re-drawn ## every time because their apparent transformation may have changed. s3 = pg.ScatterPlotItem(pxMode=False) ## Set pxMode=False to allow spots to transform with the view diff --git a/examples/ScatterPlotSpeedTest.py b/examples/ScatterPlotSpeedTest.py index 9cbf0c630d..2c64173ae5 100644 --- a/examples/ScatterPlotSpeedTest.py +++ b/examples/ScatterPlotSpeedTest.py @@ -12,7 +12,7 @@ import initExample -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE, USE_PYQT5 +from pyqtgraph.Qt import QtGui, QtCore, QT_LIB import numpy as np import pyqtgraph as pg from pyqtgraph.ptime import time @@ -20,9 +20,11 @@ app = QtGui.QApplication([]) #mw = QtGui.QMainWindow() #mw.resize(800,800) -if USE_PYSIDE: +if QT_LIB == 'PySide': from ScatterPlotSpeedTestTemplate_pyside import Ui_Form -elif USE_PYQT5: +elif QT_LIB == 'PySide2': + from ScatterPlotSpeedTestTemplate_pyside2 import Ui_Form +elif QT_LIB == 'PyQt5': from ScatterPlotSpeedTestTemplate_pyqt5 import Ui_Form else: from ScatterPlotSpeedTestTemplate_pyqt import Ui_Form diff --git a/examples/ScatterPlotSpeedTestTemplate.ui b/examples/ScatterPlotSpeedTestTemplate.ui index 6b87e85dca..5cdccf0feb 100644 --- a/examples/ScatterPlotSpeedTestTemplate.ui +++ b/examples/ScatterPlotSpeedTestTemplate.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph diff --git a/examples/ScatterPlotSpeedTestTemplate_pyqt.py b/examples/ScatterPlotSpeedTestTemplate_pyqt.py index 22136690e1..896525eb22 100644 --- a/examples/ScatterPlotSpeedTestTemplate_pyqt.py +++ b/examples/ScatterPlotSpeedTestTemplate_pyqt.py @@ -41,7 +41,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.pixelModeCheck.setText(QtGui.QApplication.translate("Form", "pixel mode", None, QtGui.QApplication.UnicodeUTF8)) self.label.setText(QtGui.QApplication.translate("Form", "Size", None, QtGui.QApplication.UnicodeUTF8)) self.randCheck.setText(QtGui.QApplication.translate("Form", "Randomize", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/examples/ScatterPlotSpeedTestTemplate_pyqt5.py b/examples/ScatterPlotSpeedTestTemplate_pyqt5.py new file mode 100644 index 0000000000..66254a5aa0 --- /dev/null +++ b/examples/ScatterPlotSpeedTestTemplate_pyqt5.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ScatterPlotSpeedTestTemplate.ui' +# +# Created: Sun Sep 18 19:21:36 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(400, 300) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setObjectName("gridLayout") + self.sizeSpin = QtWidgets.QSpinBox(Form) + self.sizeSpin.setProperty("value", 10) + self.sizeSpin.setObjectName("sizeSpin") + self.gridLayout.addWidget(self.sizeSpin, 1, 1, 1, 1) + self.pixelModeCheck = QtWidgets.QCheckBox(Form) + self.pixelModeCheck.setObjectName("pixelModeCheck") + self.gridLayout.addWidget(self.pixelModeCheck, 1, 3, 1, 1) + self.label = QtWidgets.QLabel(Form) + self.label.setObjectName("label") + self.gridLayout.addWidget(self.label, 1, 0, 1, 1) + self.plot = PlotWidget(Form) + self.plot.setObjectName("plot") + self.gridLayout.addWidget(self.plot, 0, 0, 1, 4) + self.randCheck = QtWidgets.QCheckBox(Form) + self.randCheck.setObjectName("randCheck") + self.gridLayout.addWidget(self.randCheck, 1, 2, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.pixelModeCheck.setText(QtWidgets.QApplication.translate("Form", "pixel mode", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("Form", "Size", None, -1)) + self.randCheck.setText(QtWidgets.QApplication.translate("Form", "Randomize", None, -1)) + +from pyqtgraph import PlotWidget diff --git a/examples/ScatterPlotSpeedTestTemplate_pyside.py b/examples/ScatterPlotSpeedTestTemplate_pyside.py index 690b09905c..798ebccdfb 100644 --- a/examples/ScatterPlotSpeedTestTemplate_pyside.py +++ b/examples/ScatterPlotSpeedTestTemplate_pyside.py @@ -36,7 +36,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.pixelModeCheck.setText(QtGui.QApplication.translate("Form", "pixel mode", None, QtGui.QApplication.UnicodeUTF8)) self.label.setText(QtGui.QApplication.translate("Form", "Size", None, QtGui.QApplication.UnicodeUTF8)) self.randCheck.setText(QtGui.QApplication.translate("Form", "Randomize", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/examples/ScatterPlotSpeedTestTemplate_pyside2.py b/examples/ScatterPlotSpeedTestTemplate_pyside2.py new file mode 100644 index 0000000000..b0e6281432 --- /dev/null +++ b/examples/ScatterPlotSpeedTestTemplate_pyside2.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ScatterPlotSpeedTestTemplate.ui' +# +# Created: Sun Sep 18 19:21:36 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(400, 300) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setObjectName("gridLayout") + self.sizeSpin = QtWidgets.QSpinBox(Form) + self.sizeSpin.setProperty("value", 10) + self.sizeSpin.setObjectName("sizeSpin") + self.gridLayout.addWidget(self.sizeSpin, 1, 1, 1, 1) + self.pixelModeCheck = QtWidgets.QCheckBox(Form) + self.pixelModeCheck.setObjectName("pixelModeCheck") + self.gridLayout.addWidget(self.pixelModeCheck, 1, 3, 1, 1) + self.label = QtWidgets.QLabel(Form) + self.label.setObjectName("label") + self.gridLayout.addWidget(self.label, 1, 0, 1, 1) + self.plot = PlotWidget(Form) + self.plot.setObjectName("plot") + self.gridLayout.addWidget(self.plot, 0, 0, 1, 4) + self.randCheck = QtWidgets.QCheckBox(Form) + self.randCheck.setObjectName("randCheck") + self.gridLayout.addWidget(self.randCheck, 1, 2, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.pixelModeCheck.setText(QtWidgets.QApplication.translate("Form", "pixel mode", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("Form", "Size", None, -1)) + self.randCheck.setText(QtWidgets.QApplication.translate("Form", "Randomize", None, -1)) + +from pyqtgraph import PlotWidget diff --git a/examples/ScatterPlotWidget.py b/examples/ScatterPlotWidget.py index 33503cab35..f3766d56e7 100644 --- a/examples/ScatterPlotWidget.py +++ b/examples/ScatterPlotWidget.py @@ -28,7 +28,7 @@ # Make up some tabular data with structure data = np.empty(1000, dtype=[('x_pos', float), ('y_pos', float), ('count', int), ('amplitude', float), - ('decay', float), ('type', 'S10')]) + ('decay', float), ('type', 'U10')]) strings = ['Type-A', 'Type-B', 'Type-C', 'Type-D', 'Type-E'] typeInds = np.random.randint(5, size=1000) data['type'] = np.array(strings)[typeInds] diff --git a/examples/SpinBox.py b/examples/SpinBox.py index 2fa9b1610b..ef1d0fc576 100644 --- a/examples/SpinBox.py +++ b/examples/SpinBox.py @@ -13,7 +13,7 @@ import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui import numpy as np - +import ast app = QtGui.QApplication([]) @@ -26,11 +26,20 @@ ("Float with SI-prefixed units
(n, u, m, k, M, etc)", pg.SpinBox(value=0.9, suffix='V', siPrefix=True)), ("Float with SI-prefixed units,
dec step=0.1, minStep=0.1", - pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=0.1, minStep=0.1)), + pg.SpinBox(value=1.0, suffix='PSI', siPrefix=True, dec=True, step=0.1, minStep=0.1)), ("Float with SI-prefixed units,
dec step=0.5, minStep=0.01", pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=0.5, minStep=0.01)), ("Float with SI-prefixed units,
dec step=1.0, minStep=0.001", pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=1.0, minStep=0.001)), + ("Float with custom formatting", + pg.SpinBox(value=23.07, format='${value:0.02f}', + regex='\$?(?P(-?\d+(\.\d+)?)|(-?\.\d+))$')), + ("Int with custom formatting", + pg.SpinBox(value=4567, step=1, int=True, bounds=[0,None], format='0x{value:X}', + regex='(0x)?(?P[0-9a-fA-F]+)$', + evalFunc=lambda s: ast.literal_eval('0x'+s))), + ("Integer with bounds=[10, 20] and wrapping", + pg.SpinBox(value=10, bounds=[10, 20], int=False, minStep=1, step=1, wrapping=True)), ] diff --git a/examples/Symbols.py b/examples/Symbols.py index 3dd28e13ad..417df35e6b 100755 --- a/examples/Symbols.py +++ b/examples/Symbols.py @@ -11,7 +11,7 @@ import pyqtgraph as pg app = QtGui.QApplication([]) -win = pg.GraphicsWindow(title="Scatter Plot Symbols") +win = pg.GraphicsLayoutWidget(show=True, title="Scatter Plot Symbols") win.resize(1000,600) pg.setConfigOptions(antialias=True) diff --git a/examples/VideoSpeedTest.py b/examples/VideoSpeedTest.py index e7189bf5fe..7131f9d1d4 100644 --- a/examples/VideoSpeedTest.py +++ b/examples/VideoSpeedTest.py @@ -10,14 +10,16 @@ import initExample ## Add path to library (just for examples; you do not need this) -from pyqtgraph.Qt import QtGui, QtCore, USE_PYSIDE, USE_PYQT5 +from pyqtgraph.Qt import QtGui, QtCore, QT_LIB import numpy as np import pyqtgraph as pg import pyqtgraph.ptime as ptime -if USE_PYSIDE: +if QT_LIB == 'PySide': import VideoTemplate_pyside as VideoTemplate -elif USE_PYQT5: +elif QT_LIB == 'PySide2': + import VideoTemplate_pyside2 as VideoTemplate +elif QT_LIB == 'PyQt5': import VideoTemplate_pyqt5 as VideoTemplate else: import VideoTemplate_pyqt as VideoTemplate @@ -101,6 +103,7 @@ def mkData(): dt = np.float loc = 1.0 scale = 0.1 + mx = 1.0 if ui.rgbCheck.isChecked(): data = np.random.normal(size=(frames,width,height,3), loc=loc, scale=scale) diff --git a/examples/VideoTemplate_pyside2.py b/examples/VideoTemplate_pyside2.py new file mode 100644 index 0000000000..37b7d2e8ff --- /dev/null +++ b/examples/VideoTemplate_pyside2.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'VideoTemplate.ui' +# +# Created: Sun Sep 18 19:22:41 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_MainWindow(object): + def setupUi(self, MainWindow): + MainWindow.setObjectName("MainWindow") + MainWindow.resize(695, 798) + self.centralwidget = QtWidgets.QWidget(MainWindow) + self.centralwidget.setObjectName("centralwidget") + self.gridLayout_2 = QtWidgets.QGridLayout(self.centralwidget) + self.gridLayout_2.setObjectName("gridLayout_2") + self.downsampleCheck = QtWidgets.QCheckBox(self.centralwidget) + self.downsampleCheck.setObjectName("downsampleCheck") + self.gridLayout_2.addWidget(self.downsampleCheck, 8, 0, 1, 2) + self.scaleCheck = QtWidgets.QCheckBox(self.centralwidget) + self.scaleCheck.setObjectName("scaleCheck") + self.gridLayout_2.addWidget(self.scaleCheck, 4, 0, 1, 1) + self.gridLayout = QtWidgets.QGridLayout() + self.gridLayout.setObjectName("gridLayout") + self.rawRadio = QtWidgets.QRadioButton(self.centralwidget) + self.rawRadio.setObjectName("rawRadio") + self.gridLayout.addWidget(self.rawRadio, 3, 0, 1, 1) + self.gfxRadio = QtWidgets.QRadioButton(self.centralwidget) + self.gfxRadio.setChecked(True) + self.gfxRadio.setObjectName("gfxRadio") + self.gridLayout.addWidget(self.gfxRadio, 2, 0, 1, 1) + self.stack = QtWidgets.QStackedWidget(self.centralwidget) + self.stack.setObjectName("stack") + self.page = QtWidgets.QWidget() + self.page.setObjectName("page") + self.gridLayout_3 = QtWidgets.QGridLayout(self.page) + self.gridLayout_3.setObjectName("gridLayout_3") + self.graphicsView = GraphicsView(self.page) + self.graphicsView.setObjectName("graphicsView") + self.gridLayout_3.addWidget(self.graphicsView, 0, 0, 1, 1) + self.stack.addWidget(self.page) + self.page_2 = QtWidgets.QWidget() + self.page_2.setObjectName("page_2") + self.gridLayout_4 = QtWidgets.QGridLayout(self.page_2) + self.gridLayout_4.setObjectName("gridLayout_4") + self.rawImg = RawImageWidget(self.page_2) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.rawImg.sizePolicy().hasHeightForWidth()) + self.rawImg.setSizePolicy(sizePolicy) + self.rawImg.setObjectName("rawImg") + self.gridLayout_4.addWidget(self.rawImg, 0, 0, 1, 1) + self.stack.addWidget(self.page_2) + self.page_3 = QtWidgets.QWidget() + self.page_3.setObjectName("page_3") + self.gridLayout_5 = QtWidgets.QGridLayout(self.page_3) + self.gridLayout_5.setObjectName("gridLayout_5") + self.rawGLImg = RawImageGLWidget(self.page_3) + self.rawGLImg.setObjectName("rawGLImg") + self.gridLayout_5.addWidget(self.rawGLImg, 0, 0, 1, 1) + self.stack.addWidget(self.page_3) + self.gridLayout.addWidget(self.stack, 0, 0, 1, 1) + self.rawGLRadio = QtWidgets.QRadioButton(self.centralwidget) + self.rawGLRadio.setObjectName("rawGLRadio") + self.gridLayout.addWidget(self.rawGLRadio, 4, 0, 1, 1) + self.gridLayout_2.addLayout(self.gridLayout, 1, 0, 1, 4) + self.dtypeCombo = QtWidgets.QComboBox(self.centralwidget) + self.dtypeCombo.setObjectName("dtypeCombo") + self.dtypeCombo.addItem("") + self.dtypeCombo.addItem("") + self.dtypeCombo.addItem("") + self.gridLayout_2.addWidget(self.dtypeCombo, 3, 2, 1, 1) + self.label = QtWidgets.QLabel(self.centralwidget) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 3, 0, 1, 1) + self.rgbLevelsCheck = QtWidgets.QCheckBox(self.centralwidget) + self.rgbLevelsCheck.setObjectName("rgbLevelsCheck") + self.gridLayout_2.addWidget(self.rgbLevelsCheck, 4, 1, 1, 1) + self.horizontalLayout_2 = QtWidgets.QHBoxLayout() + self.horizontalLayout_2.setObjectName("horizontalLayout_2") + self.minSpin2 = SpinBox(self.centralwidget) + self.minSpin2.setEnabled(False) + self.minSpin2.setObjectName("minSpin2") + self.horizontalLayout_2.addWidget(self.minSpin2) + self.label_3 = QtWidgets.QLabel(self.centralwidget) + self.label_3.setAlignment(QtCore.Qt.AlignCenter) + self.label_3.setObjectName("label_3") + self.horizontalLayout_2.addWidget(self.label_3) + self.maxSpin2 = SpinBox(self.centralwidget) + self.maxSpin2.setEnabled(False) + self.maxSpin2.setObjectName("maxSpin2") + self.horizontalLayout_2.addWidget(self.maxSpin2) + self.gridLayout_2.addLayout(self.horizontalLayout_2, 5, 2, 1, 1) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.minSpin1 = SpinBox(self.centralwidget) + self.minSpin1.setObjectName("minSpin1") + self.horizontalLayout.addWidget(self.minSpin1) + self.label_2 = QtWidgets.QLabel(self.centralwidget) + self.label_2.setAlignment(QtCore.Qt.AlignCenter) + self.label_2.setObjectName("label_2") + self.horizontalLayout.addWidget(self.label_2) + self.maxSpin1 = SpinBox(self.centralwidget) + self.maxSpin1.setObjectName("maxSpin1") + self.horizontalLayout.addWidget(self.maxSpin1) + self.gridLayout_2.addLayout(self.horizontalLayout, 4, 2, 1, 1) + self.horizontalLayout_3 = QtWidgets.QHBoxLayout() + self.horizontalLayout_3.setObjectName("horizontalLayout_3") + self.minSpin3 = SpinBox(self.centralwidget) + self.minSpin3.setEnabled(False) + self.minSpin3.setObjectName("minSpin3") + self.horizontalLayout_3.addWidget(self.minSpin3) + self.label_4 = QtWidgets.QLabel(self.centralwidget) + self.label_4.setAlignment(QtCore.Qt.AlignCenter) + self.label_4.setObjectName("label_4") + self.horizontalLayout_3.addWidget(self.label_4) + self.maxSpin3 = SpinBox(self.centralwidget) + self.maxSpin3.setEnabled(False) + self.maxSpin3.setObjectName("maxSpin3") + self.horizontalLayout_3.addWidget(self.maxSpin3) + self.gridLayout_2.addLayout(self.horizontalLayout_3, 6, 2, 1, 1) + self.lutCheck = QtWidgets.QCheckBox(self.centralwidget) + self.lutCheck.setObjectName("lutCheck") + self.gridLayout_2.addWidget(self.lutCheck, 7, 0, 1, 1) + self.alphaCheck = QtWidgets.QCheckBox(self.centralwidget) + self.alphaCheck.setObjectName("alphaCheck") + self.gridLayout_2.addWidget(self.alphaCheck, 7, 1, 1, 1) + self.gradient = GradientWidget(self.centralwidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.gradient.sizePolicy().hasHeightForWidth()) + self.gradient.setSizePolicy(sizePolicy) + self.gradient.setObjectName("gradient") + self.gridLayout_2.addWidget(self.gradient, 7, 2, 1, 2) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.gridLayout_2.addItem(spacerItem, 3, 3, 1, 1) + self.fpsLabel = QtWidgets.QLabel(self.centralwidget) + font = QtGui.QFont() + font.setPointSize(12) + self.fpsLabel.setFont(font) + self.fpsLabel.setAlignment(QtCore.Qt.AlignCenter) + self.fpsLabel.setObjectName("fpsLabel") + self.gridLayout_2.addWidget(self.fpsLabel, 0, 0, 1, 4) + self.rgbCheck = QtWidgets.QCheckBox(self.centralwidget) + self.rgbCheck.setObjectName("rgbCheck") + self.gridLayout_2.addWidget(self.rgbCheck, 3, 1, 1, 1) + self.label_5 = QtWidgets.QLabel(self.centralwidget) + self.label_5.setObjectName("label_5") + self.gridLayout_2.addWidget(self.label_5, 2, 0, 1, 1) + self.horizontalLayout_4 = QtWidgets.QHBoxLayout() + self.horizontalLayout_4.setObjectName("horizontalLayout_4") + self.framesSpin = QtWidgets.QSpinBox(self.centralwidget) + self.framesSpin.setButtonSymbols(QtWidgets.QAbstractSpinBox.NoButtons) + self.framesSpin.setProperty("value", 10) + self.framesSpin.setObjectName("framesSpin") + self.horizontalLayout_4.addWidget(self.framesSpin) + self.widthSpin = QtWidgets.QSpinBox(self.centralwidget) + self.widthSpin.setButtonSymbols(QtWidgets.QAbstractSpinBox.PlusMinus) + self.widthSpin.setMaximum(10000) + self.widthSpin.setProperty("value", 512) + self.widthSpin.setObjectName("widthSpin") + self.horizontalLayout_4.addWidget(self.widthSpin) + self.heightSpin = QtWidgets.QSpinBox(self.centralwidget) + self.heightSpin.setButtonSymbols(QtWidgets.QAbstractSpinBox.NoButtons) + self.heightSpin.setMaximum(10000) + self.heightSpin.setProperty("value", 512) + self.heightSpin.setObjectName("heightSpin") + self.horizontalLayout_4.addWidget(self.heightSpin) + self.gridLayout_2.addLayout(self.horizontalLayout_4, 2, 1, 1, 2) + self.sizeLabel = QtWidgets.QLabel(self.centralwidget) + self.sizeLabel.setText("") + self.sizeLabel.setObjectName("sizeLabel") + self.gridLayout_2.addWidget(self.sizeLabel, 2, 3, 1, 1) + MainWindow.setCentralWidget(self.centralwidget) + + self.retranslateUi(MainWindow) + self.stack.setCurrentIndex(2) + QtCore.QMetaObject.connectSlotsByName(MainWindow) + + def retranslateUi(self, MainWindow): + MainWindow.setWindowTitle(QtWidgets.QApplication.translate("MainWindow", "MainWindow", None, -1)) + self.downsampleCheck.setText(QtWidgets.QApplication.translate("MainWindow", "Auto downsample", None, -1)) + self.scaleCheck.setText(QtWidgets.QApplication.translate("MainWindow", "Scale Data", None, -1)) + self.rawRadio.setText(QtWidgets.QApplication.translate("MainWindow", "RawImageWidget", None, -1)) + self.gfxRadio.setText(QtWidgets.QApplication.translate("MainWindow", "GraphicsView + ImageItem", None, -1)) + self.rawGLRadio.setText(QtWidgets.QApplication.translate("MainWindow", "RawGLImageWidget", None, -1)) + self.dtypeCombo.setItemText(0, QtWidgets.QApplication.translate("MainWindow", "uint8", None, -1)) + self.dtypeCombo.setItemText(1, QtWidgets.QApplication.translate("MainWindow", "uint16", None, -1)) + self.dtypeCombo.setItemText(2, QtWidgets.QApplication.translate("MainWindow", "float", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("MainWindow", "Data type", None, -1)) + self.rgbLevelsCheck.setText(QtWidgets.QApplication.translate("MainWindow", "RGB", None, -1)) + self.label_3.setText(QtWidgets.QApplication.translate("MainWindow", "<--->", None, -1)) + self.label_2.setText(QtWidgets.QApplication.translate("MainWindow", "<--->", None, -1)) + self.label_4.setText(QtWidgets.QApplication.translate("MainWindow", "<--->", None, -1)) + self.lutCheck.setText(QtWidgets.QApplication.translate("MainWindow", "Use Lookup Table", None, -1)) + self.alphaCheck.setText(QtWidgets.QApplication.translate("MainWindow", "alpha", None, -1)) + self.fpsLabel.setText(QtWidgets.QApplication.translate("MainWindow", "FPS", None, -1)) + self.rgbCheck.setText(QtWidgets.QApplication.translate("MainWindow", "RGB", None, -1)) + self.label_5.setText(QtWidgets.QApplication.translate("MainWindow", "Image size", None, -1)) + +from pyqtgraph.widgets.RawImageWidget import RawImageGLWidget, RawImageWidget +from pyqtgraph import GradientWidget, SpinBox, GraphicsView diff --git a/examples/ViewBoxFeatures.py b/examples/ViewBoxFeatures.py index 6388e41bfc..5757924b4e 100644 --- a/examples/ViewBoxFeatures.py +++ b/examples/ViewBoxFeatures.py @@ -16,7 +16,7 @@ y = np.random.normal(size=1000) y += 5 * np.sin(x/100) -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: ____') win.resize(1000, 800) win.ci.setBorder((50, 50, 100)) diff --git a/examples/__main__.py b/examples/__main__.py index 03c41119c1..df390cb9a0 100644 --- a/examples/__main__.py +++ b/examples/__main__.py @@ -7,18 +7,31 @@ import pyqtgraph as pg import subprocess from pyqtgraph.python2_3 import basestring -from pyqtgraph.Qt import QtGui, USE_PYSIDE, USE_PYQT5 +from pyqtgraph.Qt import QtGui, QT_LIB +from .utils import buildFileList, path, examples +from .syntax import PythonHighlighter -from .utils import buildFileList, testFile, path, examples -if USE_PYSIDE: +if QT_LIB == 'PySide': from .exampleLoaderTemplate_pyside import Ui_Form -elif USE_PYQT5: +elif QT_LIB == 'PySide2': + from .exampleLoaderTemplate_pyside2 import Ui_Form +elif QT_LIB == 'PyQt5': from .exampleLoaderTemplate_pyqt5 import Ui_Form else: from .exampleLoaderTemplate_pyqt import Ui_Form +class App(QtGui.QApplication): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.paletteChanged.connect(self.onPaletteChange) + self.onPaletteChange(self.palette()) + + def onPaletteChange(self, palette): + self.dark_mode = palette.base().color().name().lower() != "#ffffff" + class ExampleLoader(QtGui.QMainWindow): def __init__(self): QtGui.QMainWindow.__init__(self) @@ -26,10 +39,14 @@ def __init__(self): self.cw = QtGui.QWidget() self.setCentralWidget(self.cw) self.ui.setupUi(self.cw) + self.setWindowTitle("PyQtGraph Examples") self.codeBtn = QtGui.QPushButton('Run Edited Code') self.codeLayout = QtGui.QGridLayout() self.ui.codeView.setLayout(self.codeLayout) + self.hl = PythonHighlighter(self.ui.codeView.document()) + app = QtGui.QApplication.instance() + app.paletteChanged.connect(self.updateTheme) self.codeLayout.addItem(QtGui.QSpacerItem(100,100,QtGui.QSizePolicy.Expanding,QtGui.QSizePolicy.Expanding), 0, 0) self.codeLayout.addWidget(self.codeBtn, 1, 1) self.codeBtn.hide() @@ -48,6 +65,28 @@ def __init__(self): self.ui.codeView.textChanged.connect(self.codeEdited) self.codeBtn.clicked.connect(self.runEditedCode) + def simulate_black_mode(self): + """ + used to simulate MacOS "black mode" on other platforms + intended for debug only, as it manage only the QPlainTextEdit + """ + # first, a dark background + c = QtGui.QColor('#171717') + p = self.ui.codeView.palette() + p.setColor(QtGui.QPalette.Active, QtGui.QPalette.Base, c) + p.setColor(QtGui.QPalette.Inactive, QtGui.QPalette.Base, c) + self.ui.codeView.setPalette(p) + # then, a light font + f = QtGui.QTextCharFormat() + f.setForeground(QtGui.QColor('white')) + self.ui.codeView.setCurrentCharFormat(f) + # finally, override application automatic detection + app = QtGui.QApplication.instance() + app.dark_mode = True + + def updateTheme(self): + self.hl = PythonHighlighter(self.ui.codeView.document()) + def populateTree(self, root, examples): for key, val in examples.items(): item = QtGui.QTreeWidgetItem([key]) @@ -112,32 +151,9 @@ def runEditedCode(self): self.loadFile(edited=True) def run(): - app = QtGui.QApplication([]) + app = App([]) loader = ExampleLoader() - app.exec_() if __name__ == '__main__': - - args = sys.argv[1:] - - if '--test' in args: - # get rid of orphaned cache files first - pg.renamePyc(path) - - files = buildFileList(examples) - if '--pyside' in args: - lib = 'PySide' - elif '--pyqt' in args or '--pyqt4' in args: - lib = 'PyQt4' - elif '--pyqt5' in args: - lib = 'PyQt5' - else: - lib = '' - - exe = sys.executable - print("Running tests:", lib, sys.executable) - for f in files: - testFile(f[0], f[1], exe, lib) - else: - run() + run() diff --git a/examples/contextMenu.py b/examples/contextMenu.py index c2c5918dbf..c08008aa7c 100644 --- a/examples/contextMenu.py +++ b/examples/contextMenu.py @@ -14,7 +14,7 @@ from pyqtgraph.Qt import QtCore, QtGui import numpy as np -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: context menu') diff --git a/examples/crosshair.py b/examples/crosshair.py index 076fab495e..584eced86a 100644 --- a/examples/crosshair.py +++ b/examples/crosshair.py @@ -13,7 +13,7 @@ #generate layout app = QtGui.QApplication([]) -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: crosshair') label = pg.LabelItem(justify='right') win.addItem(label) diff --git a/examples/customPlot.py b/examples/customPlot.py index b523fd17cc..c5e05f91ee 100644 --- a/examples/customPlot.py +++ b/examples/customPlot.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -This example demonstrates the creation of a plot with a customized -AxisItem and ViewBox. +This example demonstrates the creation of a plot with +DateAxisItem and a customized ViewBox. """ @@ -12,40 +12,6 @@ import numpy as np import time -class DateAxis(pg.AxisItem): - def tickStrings(self, values, scale, spacing): - strns = [] - rng = max(values)-min(values) - #if rng < 120: - # return pg.AxisItem.tickStrings(self, values, scale, spacing) - if rng < 3600*24: - string = '%H:%M:%S' - label1 = '%b %d -' - label2 = ' %b %d, %Y' - elif rng >= 3600*24 and rng < 3600*24*30: - string = '%d' - label1 = '%b - ' - label2 = '%b, %Y' - elif rng >= 3600*24*30 and rng < 3600*24*30*24: - string = '%b' - label1 = '%Y -' - label2 = ' %Y' - elif rng >=3600*24*30*24: - string = '%Y' - label1 = '' - label2 = '' - for x in values: - try: - strns.append(time.strftime(string, time.localtime(x))) - except ValueError: ## Windows can't handle dates before 1970 - strns.append('') - try: - label = time.strftime(label1, time.localtime(min(values)))+time.strftime(label2, time.localtime(max(values))) - except ValueError: - label = '' - #self.setLabel(text=label) - return strns - class CustomViewBox(pg.ViewBox): def __init__(self, *args, **kwds): pg.ViewBox.__init__(self, *args, **kwds) @@ -65,10 +31,10 @@ def mouseDragEvent(self, ev): app = pg.mkQApp() -axis = DateAxis(orientation='bottom') +axis = pg.DateAxisItem(orientation='bottom') vb = CustomViewBox() -pw = pg.PlotWidget(viewBox=vb, axisItems={'bottom': axis}, enableMenu=False, title="PlotItem with custom axis and ViewBox
Menu disabled, mouse behavior changed: left-drag to zoom, right-click to reset zoom") +pw = pg.PlotWidget(viewBox=vb, axisItems={'bottom': axis}, enableMenu=False, title="PlotItem with DateAxisItem and custom ViewBox
Menu disabled, mouse behavior changed: left-drag to zoom, right-click to reset zoom") dates = np.arange(8) * (3600*24*356) pw.plot(x=dates, y=[1,6,2,4,3,5,6,8], symbol='o') pw.show() diff --git a/examples/designerExample.ui b/examples/designerExample.ui index 41d06089ac..0f1695af91 100644 --- a/examples/designerExample.ui +++ b/examples/designerExample.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph diff --git a/examples/designerExample_pyside2.py b/examples/designerExample_pyside2.py new file mode 100644 index 0000000000..1e04c1ac78 --- /dev/null +++ b/examples/designerExample_pyside2.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'examples/designerExample.ui' +# +# Created: Fri Feb 16 20:31:04 2018 +# by: pyside2-uic 2.0.0 running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(400, 300) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setObjectName("gridLayout") + self.plotBtn = QtWidgets.QPushButton(Form) + self.plotBtn.setObjectName("plotBtn") + self.gridLayout.addWidget(self.plotBtn, 0, 0, 1, 1) + self.plot = PlotWidget(Form) + self.plot.setObjectName("plot") + self.gridLayout.addWidget(self.plot, 1, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.plotBtn.setText(QtWidgets.QApplication.translate("Form", "Plot!", None, -1)) + +from pyqtgraph import PlotWidget diff --git a/examples/exampleLoaderTemplate.ui b/examples/exampleLoaderTemplate.ui index a1d6bc1979..c26dbddf90 100644 --- a/examples/exampleLoaderTemplate.ui +++ b/examples/exampleLoaderTemplate.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph @@ -79,6 +79,11 @@ PyQt5 + + + PySide2 + + diff --git a/examples/exampleLoaderTemplate_pyqt.py b/examples/exampleLoaderTemplate_pyqt.py index 708839f555..f5521a8f96 100644 --- a/examples/exampleLoaderTemplate_pyqt.py +++ b/examples/exampleLoaderTemplate_pyqt.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'exampleLoaderTemplate.ui' +# Form implementation generated from reading ui file 'examples/exampleLoaderTemplate.ui' # -# Created: Sat Feb 28 10:30:29 2015 -# by: PyQt4 UI code generator 4.10.4 +# Created by: PyQt4 UI code generator 4.11.4 # # WARNING! All changes made in this file will be lost! @@ -35,7 +34,6 @@ def setupUi(self, Form): self.widget = QtGui.QWidget(self.splitter) self.widget.setObjectName(_fromUtf8("widget")) self.gridLayout = QtGui.QGridLayout(self.widget) - self.gridLayout.setMargin(0) self.gridLayout.setObjectName(_fromUtf8("gridLayout")) self.exampleTree = QtGui.QTreeWidget(self.widget) self.exampleTree.setObjectName(_fromUtf8("exampleTree")) @@ -55,6 +53,7 @@ def setupUi(self, Form): self.qtLibCombo.addItem(_fromUtf8("")) self.qtLibCombo.addItem(_fromUtf8("")) self.qtLibCombo.addItem(_fromUtf8("")) + self.qtLibCombo.addItem(_fromUtf8("")) self.gridLayout.addWidget(self.qtLibCombo, 1, 1, 1, 1) self.label_2 = QtGui.QLabel(self.widget) self.label_2.setObjectName(_fromUtf8("label_2")) @@ -68,7 +67,6 @@ def setupUi(self, Form): self.widget1 = QtGui.QWidget(self.splitter) self.widget1.setObjectName(_fromUtf8("widget1")) self.verticalLayout = QtGui.QVBoxLayout(self.widget1) - self.verticalLayout.setMargin(0) self.verticalLayout.setObjectName(_fromUtf8("verticalLayout")) self.loadedFileLabel = QtGui.QLabel(self.widget1) font = QtGui.QFont() @@ -91,7 +89,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) self.graphicsSystemCombo.setItemText(0, _translate("Form", "default", None)) self.graphicsSystemCombo.setItemText(1, _translate("Form", "native", None)) self.graphicsSystemCombo.setItemText(2, _translate("Form", "raster", None)) @@ -100,6 +98,7 @@ def retranslateUi(self, Form): self.qtLibCombo.setItemText(1, _translate("Form", "PyQt4", None)) self.qtLibCombo.setItemText(2, _translate("Form", "PySide", None)) self.qtLibCombo.setItemText(3, _translate("Form", "PyQt5", None)) + self.qtLibCombo.setItemText(4, _translate("Form", "PySide2", None)) self.label_2.setText(_translate("Form", "Graphics System:", None)) self.label.setText(_translate("Form", "Qt Library:", None)) self.loadBtn.setText(_translate("Form", "Run Example", None)) diff --git a/examples/exampleLoaderTemplate_pyqt5.py b/examples/exampleLoaderTemplate_pyqt5.py index 29c0032562..090447c276 100644 --- a/examples/exampleLoaderTemplate_pyqt5.py +++ b/examples/exampleLoaderTemplate_pyqt5.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'exampleLoaderTemplate.ui' +# Form implementation generated from reading ui file 'examples/exampleLoaderTemplate.ui' # -# Created: Sat Feb 28 10:28:50 2015 -# by: PyQt5 UI code generator 5.2.1 +# Created by: PyQt5 UI code generator 5.6 # # WARNING! All changes made in this file will be lost! @@ -41,6 +40,7 @@ def setupUi(self, Form): self.qtLibCombo.addItem("") self.qtLibCombo.addItem("") self.qtLibCombo.addItem("") + self.qtLibCombo.addItem("") self.gridLayout.addWidget(self.qtLibCombo, 1, 1, 1, 1) self.label_2 = QtWidgets.QLabel(self.widget) self.label_2.setObjectName("label_2") @@ -78,7 +78,7 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.graphicsSystemCombo.setItemText(0, _translate("Form", "default")) self.graphicsSystemCombo.setItemText(1, _translate("Form", "native")) self.graphicsSystemCombo.setItemText(2, _translate("Form", "raster")) @@ -87,6 +87,7 @@ def retranslateUi(self, Form): self.qtLibCombo.setItemText(1, _translate("Form", "PyQt4")) self.qtLibCombo.setItemText(2, _translate("Form", "PySide")) self.qtLibCombo.setItemText(3, _translate("Form", "PyQt5")) + self.qtLibCombo.setItemText(4, _translate("Form", "PySide2")) self.label_2.setText(_translate("Form", "Graphics System:")) self.label.setText(_translate("Form", "Qt Library:")) self.loadBtn.setText(_translate("Form", "Run Example")) diff --git a/examples/exampleLoaderTemplate_pyside.py b/examples/exampleLoaderTemplate_pyside.py index 61f1d09fcb..d1705d23fe 100644 --- a/examples/exampleLoaderTemplate_pyside.py +++ b/examples/exampleLoaderTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'exampleLoaderTemplate.ui' +# Form implementation generated from reading ui file 'examples/exampleLoaderTemplate.ui' # -# Created: Sat Feb 28 10:31:57 2015 -# by: pyside-uic 0.2.15 running on PySide 1.2.1 +# Created: Fri Feb 16 20:29:46 2018 +# by: pyside-uic 0.2.15 running on PySide 1.2.4 # # WARNING! All changes made in this file will be lost! @@ -41,6 +41,7 @@ def setupUi(self, Form): self.qtLibCombo.addItem("") self.qtLibCombo.addItem("") self.qtLibCombo.addItem("") + self.qtLibCombo.addItem("") self.gridLayout.addWidget(self.qtLibCombo, 1, 1, 1, 1) self.label_2 = QtGui.QLabel(self.widget) self.label_2.setObjectName("label_2") @@ -77,7 +78,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.graphicsSystemCombo.setItemText(0, QtGui.QApplication.translate("Form", "default", None, QtGui.QApplication.UnicodeUTF8)) self.graphicsSystemCombo.setItemText(1, QtGui.QApplication.translate("Form", "native", None, QtGui.QApplication.UnicodeUTF8)) self.graphicsSystemCombo.setItemText(2, QtGui.QApplication.translate("Form", "raster", None, QtGui.QApplication.UnicodeUTF8)) @@ -86,6 +87,7 @@ def retranslateUi(self, Form): self.qtLibCombo.setItemText(1, QtGui.QApplication.translate("Form", "PyQt4", None, QtGui.QApplication.UnicodeUTF8)) self.qtLibCombo.setItemText(2, QtGui.QApplication.translate("Form", "PySide", None, QtGui.QApplication.UnicodeUTF8)) self.qtLibCombo.setItemText(3, QtGui.QApplication.translate("Form", "PyQt5", None, QtGui.QApplication.UnicodeUTF8)) + self.qtLibCombo.setItemText(4, QtGui.QApplication.translate("Form", "PySide2", None, QtGui.QApplication.UnicodeUTF8)) self.label_2.setText(QtGui.QApplication.translate("Form", "Graphics System:", None, QtGui.QApplication.UnicodeUTF8)) self.label.setText(QtGui.QApplication.translate("Form", "Qt Library:", None, QtGui.QApplication.UnicodeUTF8)) self.loadBtn.setText(QtGui.QApplication.translate("Form", "Run Example", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/examples/exampleLoaderTemplate_pyside2.py b/examples/exampleLoaderTemplate_pyside2.py new file mode 100644 index 0000000000..6bef728bc5 --- /dev/null +++ b/examples/exampleLoaderTemplate_pyside2.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'examples/exampleLoaderTemplate.ui' +# +# Created: Fri Feb 16 20:30:37 2018 +# by: pyside2-uic 2.0.0 running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(846, 552) + self.gridLayout_2 = QtWidgets.QGridLayout(Form) + self.gridLayout_2.setObjectName("gridLayout_2") + self.splitter = QtWidgets.QSplitter(Form) + self.splitter.setOrientation(QtCore.Qt.Horizontal) + self.splitter.setObjectName("splitter") + self.widget = QtWidgets.QWidget(self.splitter) + self.widget.setObjectName("widget") + self.gridLayout = QtWidgets.QGridLayout(self.widget) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setObjectName("gridLayout") + self.exampleTree = QtWidgets.QTreeWidget(self.widget) + self.exampleTree.setObjectName("exampleTree") + self.exampleTree.headerItem().setText(0, "1") + self.exampleTree.header().setVisible(False) + self.gridLayout.addWidget(self.exampleTree, 0, 0, 1, 2) + self.graphicsSystemCombo = QtWidgets.QComboBox(self.widget) + self.graphicsSystemCombo.setObjectName("graphicsSystemCombo") + self.graphicsSystemCombo.addItem("") + self.graphicsSystemCombo.addItem("") + self.graphicsSystemCombo.addItem("") + self.graphicsSystemCombo.addItem("") + self.gridLayout.addWidget(self.graphicsSystemCombo, 2, 1, 1, 1) + self.qtLibCombo = QtWidgets.QComboBox(self.widget) + self.qtLibCombo.setObjectName("qtLibCombo") + self.qtLibCombo.addItem("") + self.qtLibCombo.addItem("") + self.qtLibCombo.addItem("") + self.qtLibCombo.addItem("") + self.qtLibCombo.addItem("") + self.gridLayout.addWidget(self.qtLibCombo, 1, 1, 1, 1) + self.label_2 = QtWidgets.QLabel(self.widget) + self.label_2.setObjectName("label_2") + self.gridLayout.addWidget(self.label_2, 2, 0, 1, 1) + self.label = QtWidgets.QLabel(self.widget) + self.label.setObjectName("label") + self.gridLayout.addWidget(self.label, 1, 0, 1, 1) + self.loadBtn = QtWidgets.QPushButton(self.widget) + self.loadBtn.setObjectName("loadBtn") + self.gridLayout.addWidget(self.loadBtn, 3, 1, 1, 1) + self.widget1 = QtWidgets.QWidget(self.splitter) + self.widget1.setObjectName("widget1") + self.verticalLayout = QtWidgets.QVBoxLayout(self.widget1) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setObjectName("verticalLayout") + self.loadedFileLabel = QtWidgets.QLabel(self.widget1) + font = QtGui.QFont() + font.setWeight(75) + font.setBold(True) + self.loadedFileLabel.setFont(font) + self.loadedFileLabel.setText("") + self.loadedFileLabel.setAlignment(QtCore.Qt.AlignCenter) + self.loadedFileLabel.setObjectName("loadedFileLabel") + self.verticalLayout.addWidget(self.loadedFileLabel) + self.codeView = QtWidgets.QPlainTextEdit(self.widget1) + font = QtGui.QFont() + font.setFamily("FreeMono") + self.codeView.setFont(font) + self.codeView.setObjectName("codeView") + self.verticalLayout.addWidget(self.codeView) + self.gridLayout_2.addWidget(self.splitter, 0, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.graphicsSystemCombo.setItemText(0, QtWidgets.QApplication.translate("Form", "default", None, -1)) + self.graphicsSystemCombo.setItemText(1, QtWidgets.QApplication.translate("Form", "native", None, -1)) + self.graphicsSystemCombo.setItemText(2, QtWidgets.QApplication.translate("Form", "raster", None, -1)) + self.graphicsSystemCombo.setItemText(3, QtWidgets.QApplication.translate("Form", "opengl", None, -1)) + self.qtLibCombo.setItemText(0, QtWidgets.QApplication.translate("Form", "default", None, -1)) + self.qtLibCombo.setItemText(1, QtWidgets.QApplication.translate("Form", "PyQt4", None, -1)) + self.qtLibCombo.setItemText(2, QtWidgets.QApplication.translate("Form", "PySide", None, -1)) + self.qtLibCombo.setItemText(3, QtWidgets.QApplication.translate("Form", "PyQt5", None, -1)) + self.qtLibCombo.setItemText(4, QtWidgets.QApplication.translate("Form", "PySide2", None, -1)) + self.label_2.setText(QtWidgets.QApplication.translate("Form", "Graphics System:", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("Form", "Qt Library:", None, -1)) + self.loadBtn.setText(QtWidgets.QApplication.translate("Form", "Run Example", None, -1)) + diff --git a/examples/fractal.py b/examples/fractal.py new file mode 100644 index 0000000000..d91133a527 --- /dev/null +++ b/examples/fractal.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +""" +Displays an interactive Koch fractal +""" +import initExample ## Add path to library (just for examples; you do not need this) + +from functools import reduce +import pyqtgraph as pg +from pyqtgraph.Qt import QtCore, QtGui +import numpy as np + +app = QtGui.QApplication([]) + +# Set up UI widgets +win = pg.QtGui.QWidget() +win.setWindowTitle('pyqtgraph example: fractal demo') +layout = pg.QtGui.QGridLayout() +win.setLayout(layout) +layout.setContentsMargins(0, 0, 0, 0) +depthLabel = pg.QtGui.QLabel('fractal depth:') +layout.addWidget(depthLabel, 0, 0) +depthSpin = pg.SpinBox(value=5, step=1, bounds=[1, 10], delay=0, int=True) +depthSpin.resize(100, 20) +layout.addWidget(depthSpin, 0, 1) +w = pg.GraphicsLayoutWidget() +layout.addWidget(w, 1, 0, 1, 2) +win.show() + +# Set up graphics +v = w.addViewBox() +v.setAspectLocked() +baseLine = pg.PolyLineROI([[0, 0], [1, 0], [1.5, 1], [2, 0], [3, 0]], pen=(0, 255, 0, 100), movable=False) +v.addItem(baseLine) +fc = pg.PlotCurveItem(pen=(255, 255, 255, 200), antialias=True) +v.addItem(fc) +v.autoRange() + + +transformMap = [0, 0, None] + + +def update(): + # recalculate and redraw the fractal curve + + depth = depthSpin.value() + pts = baseLine.getState()['points'] + nbseg = len(pts) - 1 + nseg = nbseg**depth + + # Get a transformation matrix for each base segment + trs = [] + v1 = pts[-1] - pts[0] + l1 = v1.length() + for i in range(len(pts)-1): + p1 = pts[i] + p2 = pts[i+1] + v2 = p2 - p1 + t = p1 - pts[0] + r = v2.angle(v1) + s = v2.length() / l1 + trs.append(pg.SRTTransform({'pos': t, 'scale': (s, s), 'angle': r})) + + basePts = [np.array(list(pt) + [1]) for pt in baseLine.getState()['points']] + baseMats = np.dstack([tr.matrix().T for tr in trs]).transpose(2, 0, 1) + + # Generate an array of matrices to transform base points + global transformMap + if transformMap[:2] != [depth, nbseg]: + # we can cache the transform index to save a little time.. + nseg = nbseg**depth + matInds = np.empty((depth, nseg), dtype=int) + for i in range(depth): + matInds[i] = np.tile(np.repeat(np.arange(nbseg), nbseg**(depth-1-i)), nbseg**i) + transformMap = [depth, nbseg, matInds] + + # Each column in matInds contains the indices referring to the base transform + # matrices that must be multiplied together to generate the final transform + # for each segment of the fractal + matInds = transformMap[2] + + # Collect all matrices needed for generating fractal curve + mats = baseMats[matInds] + + # Magic-multiply stacks of matrices together + def matmul(a, b): + return np.sum(np.transpose(a,(0,2,1))[..., None] * b[..., None, :], axis=-3) + mats = reduce(matmul, mats) + + # Transform base points through matrix array + pts = np.empty((nseg * nbseg + 1, 2)) + for l in range(len(trs)): + bp = basePts[l] + pts[l:-1:len(trs)] = np.dot(mats, bp)[:, :2] + + # Finish the curve with the last base point + pts[-1] = basePts[-1][:2] + + # update fractal curve with new points + fc.setData(pts[:,0], pts[:,1]) + + +# Update the fractal whenever the base shape or depth has changed +baseLine.sigRegionChanged.connect(update) +depthSpin.valueChanged.connect(update) + +# Initialize +update() + + +## Start Qt event loop unless running in interactive mode or using pyside. +if __name__ == '__main__': + import sys + if (sys.flags.interactive != 1) or not hasattr(QtCore, 'PYQT_VERSION'): + QtGui.QApplication.instance().exec_() + \ No newline at end of file diff --git a/examples/histogram.py b/examples/histogram.py index 2674ba305a..85fbe3f009 100644 --- a/examples/histogram.py +++ b/examples/histogram.py @@ -8,7 +8,7 @@ from pyqtgraph.Qt import QtCore, QtGui import numpy as np -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.resize(800,350) win.setWindowTitle('pyqtgraph example: Histogram') plt1 = win.addPlot() @@ -22,7 +22,7 @@ ## Using stepMode=True causes the plot to draw two lines for each sample. ## notice that len(x) == len(y)+1 -plt1.plot(x, y, stepMode=True, fillLevel=0, brush=(0,0,255,150)) +plt1.plot(x, y, stepMode=True, fillLevel=0, fillOutline=True, brush=(0,0,255,150)) ## Now draw all points as a nicely-spaced scatter plot y = pg.pseudoScatter(vals, spacing=0.15) diff --git a/examples/imageAnalysis.py b/examples/imageAnalysis.py index 13adf5ac7c..da753e34d7 100644 --- a/examples/imageAnalysis.py +++ b/examples/imageAnalysis.py @@ -21,7 +21,7 @@ win.setWindowTitle('pyqtgraph example: Image Analysis') # A plot area (ViewBox + axes) for displaying the image -p1 = win.addPlot() +p1 = win.addPlot(title="") # Item for displaying image data img = pg.ImageItem() @@ -93,6 +93,26 @@ def updateIsocurve(): isoLine.sigDragged.connect(updateIsocurve) +def imageHoverEvent(event): + """Show the position, pixel, and value under the mouse cursor. + """ + if event.isExit(): + p1.setTitle("") + return + pos = event.pos() + i, j = pos.y(), pos.x() + i = int(np.clip(i, 0, data.shape[0] - 1)) + j = int(np.clip(j, 0, data.shape[1] - 1)) + val = data[i, j] + ppos = img.mapToParent(pos) + x, y = ppos.x(), ppos.y() + p1.setTitle("pos: (%0.1f, %0.1f) pixel: (%d, %d) value: %g" % (x, y, i, j, val)) + +# Monkey-patch the image to use our custom hover function. +# This is generally discouraged (you should subclass ImageItem instead), +# but it works for a very simple use like this. +img.hoverEvent = imageHoverEvent + ## Start Qt event loop unless running in interactive mode or using pyside. if __name__ == '__main__': diff --git a/examples/initExample.py b/examples/initExample.py index c10de84e22..8bce74411e 100644 --- a/examples/initExample.py +++ b/examples/initExample.py @@ -26,6 +26,8 @@ from PyQt4 import QtGui elif 'pyqt5' in sys.argv: from PyQt5 import QtGui +elif 'pyside2' in sys.argv: + from PySide2 import QtGui else: from pyqtgraph.Qt import QtGui diff --git a/examples/isocurve.py b/examples/isocurve.py index b401dfe189..63b1699e5d 100644 --- a/examples/isocurve.py +++ b/examples/isocurve.py @@ -17,10 +17,10 @@ frames = 200 data = np.random.normal(size=(frames,30,30), loc=0, scale=100) data = np.concatenate([data, data], axis=0) -data = pg.gaussianFilter(data, (10, 10, 10))[frames/2:frames + frames/2] +data = pg.gaussianFilter(data, (10, 10, 10))[frames//2:frames + frames//2] data[:, 15:16, 15:17] += 1 -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: Isocurve') vb = win.addViewBox() img = pg.ImageItem(data[0]) diff --git a/examples/linkedViews.py b/examples/linkedViews.py index e7eb18afb6..34f2b698d4 100644 --- a/examples/linkedViews.py +++ b/examples/linkedViews.py @@ -20,7 +20,7 @@ x = np.linspace(-50, 50, 1000) y = np.sin(x) / x -win = pg.GraphicsWindow(title="pyqtgraph example: Linked Views") +win = pg.GraphicsLayoutWidget(show=True, title="pyqtgraph example: Linked Views") win.resize(800,600) win.addLabel("Linked Views", colspan=2) diff --git a/examples/logAxis.py b/examples/logAxis.py index a0c7fc533b..3b30c50b44 100644 --- a/examples/logAxis.py +++ b/examples/logAxis.py @@ -11,7 +11,7 @@ app = QtGui.QApplication([]) -w = pg.GraphicsWindow() +w = pg.GraphicsLayoutWidget(show=True) w.setWindowTitle('pyqtgraph example: logAxis') p1 = w.addPlot(0,0, title="X Semilog") p2 = w.addPlot(1,0, title="Y Semilog") diff --git a/examples/optics/pyoptic.py b/examples/optics/pyoptic.py index c2cb2ba2fa..f59f3bac13 100644 --- a/examples/optics/pyoptic.py +++ b/examples/optics/pyoptic.py @@ -110,7 +110,11 @@ def paramStateChanged(self): def __getitem__(self, item): # bug in pyside 1.2.2 causes getitem to be called inside QGraphicsObject.parentItem: - return self.getParam(item) # PySide bug: https://bugreports.qt.io/browse/PYSIDE-441 + return self.getParam(item) # PySide bug: https://bugreports.qt.io/browse/PYSIDE-671 + + def __len__(self): + # Workaround for PySide bug: https://bugreports.qt.io/browse/PYSIDE-671 + return 0 def getParam(self, param): return self.__params[param] @@ -235,35 +239,21 @@ def propagateRay(self, ray): N must already be normalized in order to achieve the desired result. """ - - - iors = [self.ior(ray['wl']), 1.0] for i in [0,1]: surface = self.surfaces[i] ior = iors[i] p1, ai = surface.intersectRay(ray) - #print "surface intersection:", p1, ai*180/3.14159 - #trans = self.sceneTransform().inverted()[0] * surface.sceneTransform() - #p1 = trans.map(p1) if p1 is None: ray.setEnd(None) break p1 = surface.mapToItem(ray, p1) - #print "adjusted position:", p1 - #ior = self.ior(ray['wl']) rd = ray['dir'] a1 = np.arctan2(rd[1], rd[0]) ar = a1 - ai + np.arcsin((np.sin(ai) * ray['ior'] / ior)) - #print [x for x in [a1, ai, (np.sin(ai) * ray['ior'] / ior), ar]] - #print ai, np.sin(ai), ray['ior'], ior ray.setEnd(p1) dp = Point(np.cos(ar), np.sin(ar)) - #p2 = p1+dp - #p1p = self.mapToScene(p1) - #p2p = self.mapToScene(p2) - #dpp = Point(p2p-p1p) ray = Ray(parent=ray, ior=ior, dir=dp) return [ray] @@ -384,20 +374,12 @@ def mkPath(self): else: ## half-height of surface can't be larger than radius h2 = min(h2, abs(r)) - - #dx = abs(r) - (abs(r)**2 - abs(h2)**2)**0.5 - #p.moveTo(-d*w/2.+ d*dx, d*h2) arc = QtCore.QRectF(0, -r, r*2, r*2) - #self.surfaces.append((arc.center(), r, h2)) a1 = np.arcsin(h2/r) * 180. / np.pi a2 = -2*a1 a1 += 180. self.path.arcMoveTo(arc, a1) self.path.arcTo(arc, a1, a2) - #if d == -1: - #p1 = QtGui.QPainterPath() - #p1.addRect(arc) - #self.paths.append(p1) self.h2 = h2 def boundingRect(self): @@ -405,8 +387,6 @@ def boundingRect(self): def paint(self, p, *args): return ## usually we let the optic draw. - #p.setPen(pg.mkPen('r')) - #p.drawPath(self.path) def intersectRay(self, ray): ## return the point of intersection and the angle of incidence @@ -527,7 +507,6 @@ def currentState(self, relativeTo=None): p2 = trans.map(pos + dir) return Point(p1), Point(p2-p1) - def setEnd(self, end): self['end'] = end self.mkPath() @@ -561,6 +540,7 @@ def trace(rays, optics): r2 = o.propagateRay(r) trace(r2, optics[1:]) + class Tracer(QtCore.QObject): """ Simple ray tracer. diff --git a/examples/optics_demos.py b/examples/optics_demos.py index 36bfc7f94a..b2ac5c8ac8 100644 --- a/examples/optics_demos.py +++ b/examples/optics_demos.py @@ -17,7 +17,7 @@ app = pg.QtGui.QApplication([]) -w = pg.GraphicsWindow(border=0.5) +w = pg.GraphicsLayoutWidget(show=True, border=0.5) w.resize(1000, 900) w.show() diff --git a/examples/parametertree.py b/examples/parametertree.py index 8d8a73523a..acfeac4dce 100644 --- a/examples/parametertree.py +++ b/examples/parametertree.py @@ -96,6 +96,16 @@ def addNew(self, typ): {'name': 'Renamable', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'renamable': True}, {'name': 'Removable', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'removable': True}, ]}, + {'name': 'Custom context menu', 'type': 'group', 'children': [ + {'name': 'List contextMenu', 'type': 'float', 'value': 0, 'context': [ + 'menu1', + 'menu2' + ]}, + {'name': 'Dict contextMenu', 'type': 'float', 'value': 0, 'context': { + 'changeName': 'Title', + 'internal': 'What the user sees', + }}, + ]}, ComplexParameter(name='Custom parameter group (reciprocal values)'), ScalableGroup(name="Expandable Parameter Group", children=[ {'name': 'ScalableParam 1', 'type': 'str', 'value': "default param 1"}, diff --git a/examples/relativity/relativity.py b/examples/relativity/relativity.py index e3f2c43504..98ef524e83 100644 --- a/examples/relativity/relativity.py +++ b/examples/relativity/relativity.py @@ -159,17 +159,21 @@ def treeChanged(self, *args): self.setAnimation(self.params['Animate']) def save(self): - fn = str(pg.QtGui.QFileDialog.getSaveFileName(self, "Save State..", "untitled.cfg", "Config Files (*.cfg)")) - if fn == '': + filename = pg.QtGui.QFileDialog.getSaveFileName(self, "Save State..", "untitled.cfg", "Config Files (*.cfg)") + if isinstance(filename, tuple): + filename = filename[0] # Qt4/5 API difference + if filename == '': return state = self.params.saveState() - pg.configfile.writeConfigFile(state, fn) + pg.configfile.writeConfigFile(state, str(filename)) def load(self): - fn = str(pg.QtGui.QFileDialog.getOpenFileName(self, "Save State..", "", "Config Files (*.cfg)")) - if fn == '': + filename = pg.QtGui.QFileDialog.getOpenFileName(self, "Save State..", "", "Config Files (*.cfg)") + if isinstance(filename, tuple): + filename = filename[0] # Qt4/5 API difference + if filename == '': return - state = pg.configfile.readConfigFile(fn) + state = pg.configfile.readConfigFile(str(filename)) self.loadState(state) def loadPreset(self, param, preset): diff --git a/examples/scrollingPlots.py b/examples/scrollingPlots.py index 623b9ab1dc..d370aa4688 100644 --- a/examples/scrollingPlots.py +++ b/examples/scrollingPlots.py @@ -8,7 +8,7 @@ from pyqtgraph.Qt import QtCore, QtGui import numpy as np -win = pg.GraphicsWindow() +win = pg.GraphicsLayoutWidget(show=True) win.setWindowTitle('pyqtgraph example: Scrolling Plots') @@ -21,7 +21,7 @@ curve2 = p2.plot(data1) ptr1 = 0 def update1(): - global data1, curve1, ptr1 + global data1, ptr1 data1[:-1] = data1[1:] # shift data in the array one sample left # (see also: np.roll) data1[-1] = np.random.normal() diff --git a/examples/syntax.py b/examples/syntax.py new file mode 100644 index 0000000000..954178275d --- /dev/null +++ b/examples/syntax.py @@ -0,0 +1,250 @@ +# based on https://github.com/art1415926535/PyQt5-syntax-highlighting + +from pyqtgraph.Qt import QtCore, QtGui + +QRegExp = QtCore.QRegExp + +QFont = QtGui.QFont +QColor = QtGui.QColor +QTextCharFormat = QtGui.QTextCharFormat +QSyntaxHighlighter = QtGui.QSyntaxHighlighter + + +def format(color, style=''): + """ + Return a QTextCharFormat with the given attributes. + """ + _color = QColor() + if type(color) is not str: + _color.setRgb(color[0], color[1], color[2]) + else: + _color.setNamedColor(color) + + _format = QTextCharFormat() + _format.setForeground(_color) + if 'bold' in style: + _format.setFontWeight(QFont.Bold) + if 'italic' in style: + _format.setFontItalic(True) + + return _format + + +class LightThemeColors: + + Red = "#B71C1C" + Pink = "#FCE4EC" + Purple = "#4A148C" + DeepPurple = "#311B92" + Indigo = "#1A237E" + Blue = "#0D47A1" + LightBlue = "#01579B" + Cyan = "#006064" + Teal = "#004D40" + Green = "#1B5E20" + LightGreen = "#33691E" + Lime = "#827717" + Yellow = "#F57F17" + Amber = "#FF6F00" + Orange = "#E65100" + DeepOrange = "#BF360C" + Brown = "#3E2723" + Grey = "#212121" + BlueGrey = "#263238" + + +class DarkThemeColors: + + Red = "#F44336" + Pink = "#F48FB1" + Purple = "#CE93D8" + DeepPurple = "#B39DDB" + Indigo = "#9FA8DA" + Blue = "#90CAF9" + LightBlue = "#81D4FA" + Cyan = "#80DEEA" + Teal = "#80CBC4" + Green = "#A5D6A7" + LightGreen = "#C5E1A5" + Lime = "#E6EE9C" + Yellow = "#FFF59D" + Amber = "#FFE082" + Orange = "#FFCC80" + DeepOrange = "#FFAB91" + Brown = "#BCAAA4" + Grey = "#EEEEEE" + BlueGrey = "#B0BEC5" + + +LIGHT_STYLES = { + 'keyword': format(LightThemeColors.Blue, 'bold'), + 'operator': format(LightThemeColors.Red, 'bold'), + 'brace': format(LightThemeColors.Purple), + 'defclass': format(LightThemeColors.Indigo, 'bold'), + 'string': format(LightThemeColors.Amber), + 'string2': format(LightThemeColors.DeepPurple), + 'comment': format(LightThemeColors.Green, 'italic'), + 'self': format(LightThemeColors.Blue, 'bold'), + 'numbers': format(LightThemeColors.Teal), +} + + +DARK_STYLES = { + 'keyword': format(DarkThemeColors.Blue, 'bold'), + 'operator': format(DarkThemeColors.Red, 'bold'), + 'brace': format(DarkThemeColors.Purple), + 'defclass': format(DarkThemeColors.Indigo, 'bold'), + 'string': format(DarkThemeColors.Amber), + 'string2': format(DarkThemeColors.DeepPurple), + 'comment': format(DarkThemeColors.Green, 'italic'), + 'self': format(DarkThemeColors.Blue, 'bold'), + 'numbers': format(DarkThemeColors.Teal), +} + + +class PythonHighlighter(QSyntaxHighlighter): + """Syntax highlighter for the Python language. + """ + # Python keywords + keywords = [ + 'and', 'assert', 'break', 'class', 'continue', 'def', + 'del', 'elif', 'else', 'except', 'exec', 'finally', + 'for', 'from', 'global', 'if', 'import', 'in', + 'is', 'lambda', 'not', 'or', 'pass', 'print', + 'raise', 'return', 'try', 'while', 'yield', + 'None', 'True', 'False', 'async', 'await', + ] + + # Python operators + operators = [ + '=', + # Comparison + '==', '!=', '<', '<=', '>', '>=', + # Arithmetic + '\+', '-', '\*', '/', '//', '\%', '\*\*', + # In-place + '\+=', '-=', '\*=', '/=', '\%=', + # Bitwise + '\^', '\|', '\&', '\~', '>>', '<<', + ] + + # Python braces + braces = [ + '\{', '\}', '\(', '\)', '\[', '\]', + ] + + def __init__(self, document): + QSyntaxHighlighter.__init__(self, document) + + # Multi-line strings (expression, flag, style) + # FIXME: The triple-quotes in these two lines will mess up the + # syntax highlighting from this point onward + self.tri_single = (QRegExp("'''"), 1, 'string2') + self.tri_double = (QRegExp('"""'), 2, 'string2') + + rules = [] + + # Keyword, operator, and brace rules + rules += [(r'\b%s\b' % w, 0, 'keyword') + for w in PythonHighlighter.keywords] + rules += [(r'%s' % o, 0, 'operator') + for o in PythonHighlighter.operators] + rules += [(r'%s' % b, 0, 'brace') + for b in PythonHighlighter.braces] + + # All other rules + rules += [ + + # 'self' + (r'\bself\b', 0, 'self'), + + # 'def' followed by an identifier + (r'\bdef\b\s*(\w+)', 1, 'defclass'), + # 'class' followed by an identifier + (r'\bclass\b\s*(\w+)', 1, 'defclass'), + + # Numeric literals + (r'\b[+-]?[0-9]+[lL]?\b', 0, 'numbers'), + (r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b', 0, 'numbers'), + (r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b', 0, 'numbers'), + + # Double-quoted string, possibly containing escape sequences + (r'"[^"\\]*(\\.[^"\\]*)*"', 0, 'string'), + # Single-quoted string, possibly containing escape sequences + (r"'[^'\\]*(\\.[^'\\]*)*'", 0, 'string'), + + # From '#' until a newline + (r'#[^\n]*', 0, 'comment'), + + ] + + # Build a QRegExp for each pattern + self.rules = [(QRegExp(pat), index, fmt) + for (pat, index, fmt) in rules] + + @property + def styles(self): + app = QtGui.QApplication.instance() + return DARK_STYLES if app.dark_mode else LIGHT_STYLES + + def highlightBlock(self, text): + """Apply syntax highlighting to the given block of text. + """ + # Do other syntax formatting + for expression, nth, format in self.rules: + index = expression.indexIn(text, 0) + format = self.styles[format] + + while index >= 0: + # We actually want the index of the nth match + index = expression.pos(nth) + length = len(expression.cap(nth)) + self.setFormat(index, length, format) + index = expression.indexIn(text, index + length) + + self.setCurrentBlockState(0) + + # Do multi-line strings + in_multiline = self.match_multiline(text, *self.tri_single) + if not in_multiline: + in_multiline = self.match_multiline(text, *self.tri_double) + + def match_multiline(self, text, delimiter, in_state, style): + """Do highlighting of multi-line strings. ``delimiter`` should be a + ``QRegExp`` for triple-single-quotes or triple-double-quotes, and + ``in_state`` should be a unique integer to represent the corresponding + state changes when inside those strings. Returns True if we're still + inside a multi-line string when this function is finished. + """ + # If inside triple-single quotes, start at 0 + if self.previousBlockState() == in_state: + start = 0 + add = 0 + # Otherwise, look for the delimiter on this line + else: + start = delimiter.indexIn(text) + # Move past this match + add = delimiter.matchedLength() + + # As long as there's a delimiter match on this line... + while start >= 0: + # Look for the ending delimiter + end = delimiter.indexIn(text, start + add) + # Ending delimiter on this line? + if end >= add: + length = end - start + add + delimiter.matchedLength() + self.setCurrentBlockState(0) + # No; multi-line string + else: + self.setCurrentBlockState(in_state) + length = len(text) - start + add + # Apply formatting + self.setFormat(start, length, self.styles[style]) + # Look for the next match + start = delimiter.indexIn(text, start + length) + + # Return True if still inside a multi-line string, False otherwise + if self.currentBlockState() == in_state: + return True + else: + return False diff --git a/examples/test_examples.py b/examples/test_examples.py index 3e6b8200ce..a9fecca22b 100644 --- a/examples/test_examples.py +++ b/examples/test_examples.py @@ -1,20 +1,52 @@ +# -*- coding: utf-8 -*- from __future__ import print_function, division, absolute_import -from pyqtgraph import Qt from . import utils +from collections import namedtuple +from pyqtgraph import Qt +import errno +import importlib import itertools import pytest +import os, sys +import subprocess +import time -# apparently importlib does not exist in python 2.6... -try: - import importlib -except ImportError: - # we are on python 2.6 - print("If you want to test the examples, please install importlib from " - "pypi\n\npip install importlib\n\n") - pass - -files = utils.buildFileList(utils.examples) -frontends = {Qt.PYQT4: False, Qt.PYSIDE: False} + +path = os.path.abspath(os.path.dirname(__file__)) + +# printing on travis ci frequently leads to "interrupted system call" errors. +# as a workaround, we overwrite the built-in print function (bleh) +if os.getenv('TRAVIS') is not None: + if sys.version_info[0] < 3: + import __builtin__ as builtins + else: + import builtins + + def flaky_print(*args): + """Wrapper for print that retries in case of IOError. + """ + count = 0 + while count < 5: + count += 1 + try: + orig_print(*args) + break + except IOError: + if count >= 5: + raise + pass + orig_print = builtins.print + builtins.print = flaky_print + print("Installed wrapper for flaky print.") + + +files = sorted(set(utils.buildFileList(utils.examples))) +frontends = { + Qt.PYQT4: False, + Qt.PYQT5: False, + Qt.PYSIDE: False, + Qt.PYSIDE2: False +} # sort out which of the front ends are available for frontend in frontends.keys(): try: @@ -23,15 +55,204 @@ except ImportError: pass +installedFrontends = sorted([ + frontend for frontend, isPresent in frontends.items() if isPresent +]) + +exceptionCondition = namedtuple("exceptionCondition", ["condition", "reason"]) +conditionalExamples = { + "hdf5.py": exceptionCondition( + False, + reason="Example requires user interaction" + ), + "RemoteSpeedTest.py": exceptionCondition( + False, + reason="Test is being problematic on CI machines" + ), + "optics_demos.py": exceptionCondition( + not frontends[Qt.PYSIDE], + reason=( + "Test fails due to PySide bug: ", + "https://bugreports.qt.io/browse/PYSIDE-671" + ) + ), + 'GLVolumeItem.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLIsosurface.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLSurfacePlot.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLScatterPlotItem.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLshaders.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLLinePlotItem.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLMeshItem.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ), + 'GLImageItem.py': exceptionCondition( + not(sys.platform == "darwin" and + sys.version_info[0] == 2 and + (frontends[Qt.PYQT4] or frontends[Qt.PYSIDE])), + reason=( + "glClear does not work on macOS + Python2.7 + Qt4: ", + "https://github.com/pyqtgraph/pyqtgraph/issues/939" + ) + ) +} +@pytest.mark.skipif( + Qt.QT_LIB == "PySide2" + and tuple(map(int, Qt.PySide2.__version__.split("."))) >= (5, 14) + and tuple(map(int, Qt.PySide2.__version__.split("."))) < (5, 14, 2, 2), + reason="new PySide2 doesn't have loadUi functionality" +) @pytest.mark.parametrize( - "frontend, f", itertools.product(sorted(list(frontends.keys())), files)) -def test_examples(frontend, f): - # Test the examples with all available front-ends - print('frontend = %s. f = %s' % (frontend, f)) - if not frontends[frontend]: - pytest.skip('%s is not installed. Skipping tests' % frontend) - utils.testFile(f[0], f[1], utils.sys.executable, frontend) + "frontend, f", + [ + pytest.param( + frontend, + f, + marks=pytest.mark.skipif( + conditionalExamples[f[1]].condition is False, + reason=conditionalExamples[f[1]].reason + ) if f[1] in conditionalExamples.keys() else (), + ) + for frontend, f, in itertools.product(installedFrontends, files) + ], + ids = [ + " {} - {} ".format(f[1], frontend) + for frontend, f in itertools.product( + installedFrontends, + files + ) + ] +) +def testExamples(frontend, f, graphicsSystem=None): + # runExampleFile(f[0], f[1], sys.executable, frontend) + + name, file = f + global path + fn = os.path.join(path, file) + os.chdir(path) + sys.stdout.write("{} ".format(name)) + sys.stdout.flush() + import1 = "import %s" % frontend if frontend != '' else '' + import2 = os.path.splitext(os.path.split(fn)[1])[0] + graphicsSystem = ( + '' if graphicsSystem is None else "pg.QtGui.QApplication.setGraphicsSystem('%s')" % graphicsSystem + ) + code = """ +try: + %s + import initExample + import pyqtgraph as pg + %s + import %s + import sys + print("test complete") + sys.stdout.flush() + import time + while True: ## run a little event loop + pg.QtGui.QApplication.processEvents() + time.sleep(0.01) +except: + print("test failed") + raise + +""" % (import1, graphicsSystem, import2) + if sys.platform.startswith('win'): + process = subprocess.Popen([sys.executable], + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE) + else: + process = subprocess.Popen(['exec %s -i' % (sys.executable)], + shell=True, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE) + process.stdin.write(code.encode('UTF-8')) + process.stdin.close() + output = '' + fail = False + while True: + try: + c = process.stdout.read(1).decode() + except IOError as err: + if err.errno == errno.EINTR: + # Interrupted system call; just try again. + c = '' + else: + raise + output += c + + if output.endswith('test complete'): + break + if output.endswith('test failed'): + fail = True + break + time.sleep(1) + process.kill() + #res = process.communicate() + res = (process.stdout.read(), process.stderr.read()) + if (fail or + 'exception' in res[1].decode().lower() or + 'error' in res[1].decode().lower()): + print(res[0].decode()) + print(res[1].decode()) + pytest.fail("{}\n{}\nFailed {} Example Test Located in {} " + .format(res[0].decode(), res[1].decode(), name, file), + pytrace=False) if __name__ == "__main__": pytest.cmdline.main() diff --git a/examples/utils.py b/examples/utils.py index cbdf69c6d1..041d17d78c 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -1,8 +1,5 @@ from __future__ import division, print_function, absolute_import -import subprocess -import time import os -import sys from pyqtgraph.pgcollections import OrderedDict from pyqtgraph.python2_3 import basestring @@ -17,7 +14,9 @@ ('Crosshair / Mouse interaction', 'crosshair.py'), ('Data Slicing', 'DataSlicing.py'), ('Plot Customization', 'customPlot.py'), + ('Timestamps on x axis', 'DateAxisItem.py'), ('Image Analysis', 'imageAnalysis.py'), + ('ViewBox Features', 'ViewBoxFeatures.py'), ('Dock widgets', 'dockarea.py'), ('Console', 'ConsoleWidget.py'), ('Histograms', 'histogram.py'), @@ -31,6 +30,7 @@ ('Optics', 'optics_demos.py'), ('Special relativity', 'relativity_demo.py'), ('Verlet chain', 'verlet_chain_demo.py'), + ('Koch Fractal', 'fractal.py'), ])), ('GraphicsItems', OrderedDict([ ('Scatter Plot', 'ScatterPlot.py'), @@ -48,7 +48,7 @@ ('Text Item', 'text.py'), ('Linked Views', 'linkedViews.py'), ('Arrow', 'Arrow.py'), - ('ViewBox', 'ViewBox.py'), + ('ViewBox', 'ViewBoxFeatures.py'), ('Custom Graphics', 'customGraphicsItem.py'), ('Labeled Graph', 'CustomGraphItem.py'), ])), @@ -83,7 +83,6 @@ #('VerticalLabel', '../widgets/VerticalLabel.py'), ('JoystickButton', 'JoystickButton.py'), ])), - ('Flowcharts', 'Flowchart.py'), ('Custom Flowchart Nodes', 'FlowchartCustomNode.py'), ]) @@ -100,66 +99,3 @@ def buildFileList(examples, files=None): else: buildFileList(val, files) return files - -def testFile(name, f, exe, lib, graphicsSystem=None): - global path - fn = os.path.join(path,f) - #print "starting process: ", fn - os.chdir(path) - sys.stdout.write(name) - sys.stdout.flush() - - import1 = "import %s" % lib if lib != '' else '' - import2 = os.path.splitext(os.path.split(fn)[1])[0] - graphicsSystem = '' if graphicsSystem is None else "pg.QtGui.QApplication.setGraphicsSystem('%s')" % graphicsSystem - code = """ -try: - %s - import initExample - import pyqtgraph as pg - %s - import %s - import sys - print("test complete") - sys.stdout.flush() - import time - while True: ## run a little event loop - pg.QtGui.QApplication.processEvents() - time.sleep(0.01) -except: - print("test failed") - raise - -""" % (import1, graphicsSystem, import2) - - if sys.platform.startswith('win'): - process = subprocess.Popen([exe], stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE) - process.stdin.write(code.encode('UTF-8')) - process.stdin.close() - else: - process = subprocess.Popen(['exec %s -i' % (exe)], shell=True, stdin=subprocess.PIPE, stderr=subprocess.PIPE, stdout=subprocess.PIPE) - process.stdin.write(code.encode('UTF-8')) - process.stdin.close() ##? - output = '' - fail = False - while True: - c = process.stdout.read(1).decode() - output += c - #sys.stdout.write(c) - #sys.stdout.flush() - if output.endswith('test complete'): - break - if output.endswith('test failed'): - fail = True - break - time.sleep(1) - process.kill() - #res = process.communicate() - res = (process.stdout.read(), process.stderr.read()) - - if fail or 'exception' in res[1].decode().lower() or 'error' in res[1].decode().lower(): - print('.' * (50-len(name)) + 'FAILED') - print(res[0].decode()) - print(res[1].decode()) - else: - print('.' * (50-len(name)) + 'passed') diff --git a/examples/verlet_chain/chain.py b/examples/verlet_chain/chain.py index 6eb3501ab2..1c4f240368 100644 --- a/examples/verlet_chain/chain.py +++ b/examples/verlet_chain/chain.py @@ -32,8 +32,6 @@ def init(self): if self.initialized: return - assert None not in [self.pos, self.mass, self.links, self.lengths] - if self.fixed is None: self.fixed = np.zeros(self.pos.shape[0], dtype=bool) if self.push is None: diff --git a/pyqtgraph/GraphicsScene/GraphicsScene.py b/pyqtgraph/GraphicsScene/GraphicsScene.py index 952a241566..b67e44ef91 100644 --- a/pyqtgraph/GraphicsScene/GraphicsScene.py +++ b/pyqtgraph/GraphicsScene/GraphicsScene.py @@ -1,6 +1,8 @@ +# -*- coding: utf-8 -*- import weakref +import warnings + from ..Qt import QtCore, QtGui -from ..python2_3 import sortList, cmp from ..Point import Point from .. import functions as fn from .. import ptime as ptime @@ -36,6 +38,18 @@ class GraphicsScene(QtGui.QGraphicsScene): This lets us indicate unambiguously to the user which item they are about to click/drag on * Eats mouseMove events that occur too soon after a mouse press. * Reimplements items() and itemAt() to circumvent PyQt bug + + ====================== ================================================================== + **Signals** + sigMouseClicked(event) Emitted when the mouse is clicked over the scene. Use ev.pos() to + get the click position relative to the item that was clicked on, + or ev.scenePos() to get the click position in scene coordinates. + See :class:`pyqtgraph.GraphicsScene.MouseClickEvent`. + sigMouseMoved(pos) Emitted when the mouse cursor moves over the scene. The position + is given in scene coordinates. + sigMouseHover(items) Emitted when the mouse is moved over the scene. Items is a list + of items under the cursor. + ====================== ================================================================== Mouse interaction is as follows: @@ -76,15 +90,11 @@ class GraphicsScene(QtGui.QGraphicsScene): @classmethod def registerObject(cls, obj): - """ - Workaround for PyQt bug in qgraphicsscene.items() - All subclasses of QGraphicsObject must register themselves with this function. - (otherwise, mouse interaction with those objects will likely fail) - """ - if HAVE_SIP and isinstance(obj, sip.wrapper): - cls._addressCache[sip.unwrapinstance(sip.cast(obj, QtGui.QGraphicsItem))] = obj - - + warnings.warn( + "'registerObject' is deprecated and does nothing.", + DeprecationWarning, stacklevel=2 + ) + def __init__(self, clickRadius=2, moveDistance=5, parent=None): QtGui.QGraphicsScene.__init__(self, parent) self.setClickRadius(clickRadius) @@ -171,12 +181,14 @@ def mouseMoveEvent(self, ev): if int(ev.buttons() & btn) == 0: continue if int(btn) not in self.dragButtons: ## see if we've dragged far enough yet - cev = [e for e in self.clickEvents if int(e.button()) == int(btn)][0] - dist = Point(ev.scenePos() - cev.scenePos()).length() - if dist == 0 or (dist < self._moveDistance and now - cev.time() < self.minDragTime): - continue - init = init or (len(self.dragButtons) == 0) ## If this is the first button to be dragged, then init=True - self.dragButtons.append(int(btn)) + cev = [e for e in self.clickEvents if int(e.button()) == int(btn)] + if cev: + cev = cev[0] + dist = Point(ev.scenePos() - cev.scenePos()).length() + if dist == 0 or (dist < self._moveDistance and now - cev.time() < self.minDragTime): + continue + init = init or (len(self.dragButtons) == 0) ## If this is the first button to be dragged, then init=True + self.dragButtons.append(int(btn)) ## If we have dragged buttons, deliver a drag event if len(self.dragButtons) > 0: @@ -196,10 +208,11 @@ def mouseReleaseEvent(self, ev): self.dragButtons.remove(ev.button()) else: cev = [e for e in self.clickEvents if int(e.button()) == int(ev.button())] - if self.sendClickEvent(cev[0]): - #print "sent click event" - ev.accept() - self.clickEvents.remove(cev[0]) + if cev: + if self.sendClickEvent(cev[0]): + #print "sent click event" + ev.accept() + self.clickEvents.remove(cev[0]) if int(ev.buttons()) == 0: self.dragItem = None @@ -251,7 +264,8 @@ def sendHoverEvents(self, ev, exitOnly=False): for item in prevItems: event.currentItem = item try: - item.hoverEvent(event) + if item.scene() is self: + item.hoverEvent(event) except: debug.printExc("Error sending hover exit event:") finally: @@ -276,7 +290,7 @@ def sendDragEvent(self, ev, init=False, final=False): else: acceptedItem = None - if acceptedItem is not None: + if acceptedItem is not None and acceptedItem.scene() is self: #print "Drag -> pre-selected item:", acceptedItem self.dragItem = acceptedItem event.currentItem = self.dragItem @@ -352,46 +366,15 @@ def sendClickEvent(self, ev): return ev.isAccepted() def items(self, *args): - #print 'args:', args items = QtGui.QGraphicsScene.items(self, *args) - ## PyQt bug: items() returns a list of QGraphicsItem instances. If the item is subclassed from QGraphicsObject, - ## then the object returned will be different than the actual item that was originally added to the scene - items2 = list(map(self.translateGraphicsItem, items)) - #if HAVE_SIP and isinstance(self, sip.wrapper): - #items2 = [] - #for i in items: - #addr = sip.unwrapinstance(sip.cast(i, QtGui.QGraphicsItem)) - #i2 = GraphicsScene._addressCache.get(addr, i) - ##print i, "==>", i2 - #items2.append(i2) - #print 'items:', items - return items2 + return self.translateGraphicsItems(items) def selectedItems(self, *args): items = QtGui.QGraphicsScene.selectedItems(self, *args) - ## PyQt bug: items() returns a list of QGraphicsItem instances. If the item is subclassed from QGraphicsObject, - ## then the object returned will be different than the actual item that was originally added to the scene - #if HAVE_SIP and isinstance(self, sip.wrapper): - #items2 = [] - #for i in items: - #addr = sip.unwrapinstance(sip.cast(i, QtGui.QGraphicsItem)) - #i2 = GraphicsScene._addressCache.get(addr, i) - ##print i, "==>", i2 - #items2.append(i2) - items2 = list(map(self.translateGraphicsItem, items)) - - #print 'items:', items - return items2 + return self.translateGraphicsItems(items) def itemAt(self, *args): item = QtGui.QGraphicsScene.itemAt(self, *args) - - ## PyQt bug: items() returns a list of QGraphicsItem instances. If the item is subclassed from QGraphicsObject, - ## then the object returned will be different than the actual item that was originally added to the scene - #if HAVE_SIP and isinstance(self, sip.wrapper): - #addr = sip.unwrapinstance(sip.cast(item, QtGui.QGraphicsItem)) - #item = GraphicsScene._addressCache.get(addr, item) - #return item return self.translateGraphicsItem(item) def itemsNearEvent(self, event, selMode=QtCore.Qt.IntersectsItemShape, sortOrder=QtCore.Qt.DescendingOrder, hoverable=False): @@ -423,6 +406,8 @@ def itemsNearEvent(self, event, selMode=QtCore.Qt.IntersectsItemShape, sortOrder for item in items: if hoverable and not hasattr(item, 'hoverEvent'): continue + if item.scene() is not self: + continue shape = item.shape() # Note: default shape() returns boundingRect() if shape is None: continue @@ -436,7 +421,7 @@ def absZValue(item): return 0 return item.zValue() + absZValue(item.parentItem()) - sortList(items2, lambda a,b: cmp(absZValue(b), absZValue(a))) + items2.sort(key=absZValue, reverse=True) return items2 @@ -536,15 +521,16 @@ def showExportDialog(self): @staticmethod def translateGraphicsItem(item): - ## for fixing pyqt bugs where the wrong item is returned + # This function is intended as a workaround for a problem with older + # versions of PyQt (< 4.9?), where methods returning 'QGraphicsItem *' + # lose the type of the QGraphicsObject subclasses and instead return + # generic QGraphicsItem wrappers. if HAVE_SIP and isinstance(item, sip.wrapper): - addr = sip.unwrapinstance(sip.cast(item, QtGui.QGraphicsItem)) - item = GraphicsScene._addressCache.get(addr, item) + obj = item.toGraphicsObject() + if obj is not None: + item = obj return item @staticmethod def translateGraphicsItems(items): return list(map(GraphicsScene.translateGraphicsItem, items)) - - - diff --git a/pyqtgraph/GraphicsScene/exportDialog.py b/pyqtgraph/GraphicsScene/exportDialog.py index 2676a3b405..61f2233d75 100644 --- a/pyqtgraph/GraphicsScene/exportDialog.py +++ b/pyqtgraph/GraphicsScene/exportDialog.py @@ -1,12 +1,14 @@ -from ..Qt import QtCore, QtGui, USE_PYSIDE, USE_PYQT5 +from ..Qt import QtCore, QtGui, QT_LIB from .. import exporters as exporters from .. import functions as fn from ..graphicsItems.ViewBox import ViewBox from ..graphicsItems.PlotItem import PlotItem -if USE_PYSIDE: +if QT_LIB == 'PySide': from . import exportDialogTemplate_pyside as exportDialogTemplate -elif USE_PYQT5: +elif QT_LIB == 'PySide2': + from . import exportDialogTemplate_pyside2 as exportDialogTemplate +elif QT_LIB == 'PyQt5': from . import exportDialogTemplate_pyqt5 as exportDialogTemplate else: from . import exportDialogTemplate_pyqt as exportDialogTemplate @@ -20,7 +22,7 @@ def __init__(self, scene): self.shown = False self.currentExporter = None self.scene = scene - + self.selectBox = QtGui.QGraphicsRectItem() self.selectBox.setPen(fn.mkPen('y', width=3, style=QtCore.Qt.DashLine)) self.selectBox.hide() @@ -119,7 +121,9 @@ def exportFormatChanged(self, item, prev): return expClass = self.exporterClasses[str(item.text())] exp = expClass(item=self.ui.itemTree.currentItem().gitem) + params = exp.parameters() + if params is None: self.ui.paramTree.clear() else: diff --git a/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside2.py b/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside2.py new file mode 100644 index 0000000000..6c0fec4761 --- /dev/null +++ b/pyqtgraph/GraphicsScene/exportDialogTemplate_pyside2.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'exportDialogTemplate.ui' +# +# Created: Sun Sep 18 19:19:58 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(241, 367) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setSpacing(0) + self.gridLayout.setObjectName("gridLayout") + self.label = QtWidgets.QLabel(Form) + self.label.setObjectName("label") + self.gridLayout.addWidget(self.label, 0, 0, 1, 3) + self.itemTree = QtWidgets.QTreeWidget(Form) + self.itemTree.setObjectName("itemTree") + self.itemTree.headerItem().setText(0, "1") + self.itemTree.header().setVisible(False) + self.gridLayout.addWidget(self.itemTree, 1, 0, 1, 3) + self.label_2 = QtWidgets.QLabel(Form) + self.label_2.setObjectName("label_2") + self.gridLayout.addWidget(self.label_2, 2, 0, 1, 3) + self.formatList = QtWidgets.QListWidget(Form) + self.formatList.setObjectName("formatList") + self.gridLayout.addWidget(self.formatList, 3, 0, 1, 3) + self.exportBtn = QtWidgets.QPushButton(Form) + self.exportBtn.setObjectName("exportBtn") + self.gridLayout.addWidget(self.exportBtn, 6, 1, 1, 1) + self.closeBtn = QtWidgets.QPushButton(Form) + self.closeBtn.setObjectName("closeBtn") + self.gridLayout.addWidget(self.closeBtn, 6, 2, 1, 1) + self.paramTree = ParameterTree(Form) + self.paramTree.setObjectName("paramTree") + self.paramTree.headerItem().setText(0, "1") + self.paramTree.header().setVisible(False) + self.gridLayout.addWidget(self.paramTree, 5, 0, 1, 3) + self.label_3 = QtWidgets.QLabel(Form) + self.label_3.setObjectName("label_3") + self.gridLayout.addWidget(self.label_3, 4, 0, 1, 3) + self.copyBtn = QtWidgets.QPushButton(Form) + self.copyBtn.setObjectName("copyBtn") + self.gridLayout.addWidget(self.copyBtn, 6, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Export", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("Form", "Item to export:", None, -1)) + self.label_2.setText(QtWidgets.QApplication.translate("Form", "Export format", None, -1)) + self.exportBtn.setText(QtWidgets.QApplication.translate("Form", "Export", None, -1)) + self.closeBtn.setText(QtWidgets.QApplication.translate("Form", "Close", None, -1)) + self.label_3.setText(QtWidgets.QApplication.translate("Form", "Export options", None, -1)) + self.copyBtn.setText(QtWidgets.QApplication.translate("Form", "Copy", None, -1)) + +from ..parametertree import ParameterTree diff --git a/pyqtgraph/PlotData.py b/pyqtgraph/PlotData.py index e5faadda02..f27605088e 100644 --- a/pyqtgraph/PlotData.py +++ b/pyqtgraph/PlotData.py @@ -1,3 +1,4 @@ +import numpy as np class PlotData(object): @@ -50,7 +51,3 @@ def min(self, field): mn = np.min(self[field]) self.minVals[field] = mn return mn - - - - \ No newline at end of file diff --git a/pyqtgraph/Point.py b/pyqtgraph/Point.py index 9e6491ebac..3b4dacf355 100644 --- a/pyqtgraph/Point.py +++ b/pyqtgraph/Point.py @@ -105,7 +105,13 @@ def _math_(self, op, x): def length(self): """Returns the vector length of this Point.""" - return (self[0]**2 + self[1]**2) ** 0.5 + try: + return (self[0]**2 + self[1]**2) ** 0.5 + except OverflowError: + try: + return self[1] / np.sin(np.arctan2(self[1], self[0])) + except OverflowError: + return np.inf def norm(self): """Returns a vector in the same direction with unit length.""" diff --git a/pyqtgraph/Qt.py b/pyqtgraph/Qt.py index 2ed9d6f90a..6035d7ac05 100644 --- a/pyqtgraph/Qt.py +++ b/pyqtgraph/Qt.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ This module exists to smooth out some of the differences between PySide and PyQt4: @@ -9,22 +10,23 @@ """ -import os, sys, re, time +import os, sys, re, time, subprocess, warnings from .python2_3 import asUnicode PYSIDE = 'PySide' +PYSIDE2 = 'PySide2' PYQT4 = 'PyQt4' PYQT5 = 'PyQt5' QT_LIB = os.getenv('PYQTGRAPH_QT_LIB') -## Automatically determine whether to use PyQt or PySide (unless specified by +## Automatically determine which Qt package to use (unless specified by ## environment variable). ## This is done by first checking to see whether one of the libraries ## is already imported. If not, then attempt to import PyQt4, then PySide. if QT_LIB is None: - libOrder = [PYQT4, PYSIDE, PYQT5] + libOrder = [PYQT4, PYSIDE, PYQT5, PYSIDE2] for lib in libOrder: if lib in sys.modules: @@ -41,132 +43,215 @@ pass if QT_LIB is None: - raise Exception("PyQtGraph requires one of PyQt4, PyQt5 or PySide; none of these packages could be imported.") + raise Exception("PyQtGraph requires one of PyQt4, PyQt5, PySide or PySide2; none of these packages could be imported.") -if QT_LIB == PYSIDE: - from PySide import QtGui, QtCore, QtOpenGL, QtSvg - try: - from PySide import QtTest - if not hasattr(QtTest.QTest, 'qWait'): - @staticmethod - def qWait(msec): - start = time.time() - QtGui.QApplication.processEvents() - while time.time() < start + msec * 0.001: - QtGui.QApplication.processEvents() - QtTest.QTest.qWait = qWait - - except ImportError: - pass - import PySide - try: - from PySide import shiboken - isQObjectAlive = shiboken.isValid - except ImportError: - def isQObjectAlive(obj): - try: - if hasattr(obj, 'parent'): - obj.parent() - elif hasattr(obj, 'parentItem'): - obj.parentItem() - else: - raise Exception("Cannot determine whether Qt object %s is still alive." % obj) - except RuntimeError: - return False - else: - return True - - VERSION_INFO = 'PySide ' + PySide.__version__ - - # Make a loadUiType function like PyQt has - - # Credit: - # http://stackoverflow.com/questions/4442286/python-code-genration-with-pyside-uic/14195313#14195313 - - class StringIO(object): - """Alternative to built-in StringIO needed to circumvent unicode/ascii issues""" - def __init__(self): - self.data = [] + +class FailedImport(object): + """Used to defer ImportErrors until we are sure the module is needed. + """ + def __init__(self, err): + self.err = err - def write(self, data): - self.data.append(data) - - def getvalue(self): - return ''.join(map(asUnicode, self.data)).encode('utf8') + def __getattr__(self, attr): + raise self.err + + +def _isQObjectAlive(obj): + """An approximation of PyQt's isQObjectAlive(). + """ + try: + if hasattr(obj, 'parent'): + obj.parent() + elif hasattr(obj, 'parentItem'): + obj.parentItem() + else: + raise Exception("Cannot determine whether Qt object %s is still alive." % obj) + except RuntimeError: + return False + else: + return True + + +# Make a loadUiType function like PyQt has + +# Credit: +# http://stackoverflow.com/questions/4442286/python-code-genration-with-pyside-uic/14195313#14195313 + +class _StringIO(object): + """Alternative to built-in StringIO needed to circumvent unicode/ascii issues""" + def __init__(self): + self.data = [] + + def write(self, data): + self.data.append(data) - def loadUiType(uiFile): - """ - Pyside "loadUiType" command like PyQt4 has one, so we have to convert - the ui file to py code in-memory first and then execute it in a - special frame to retrieve the form_class. - - from stackoverflow: http://stackoverflow.com/a/14195313/3781327 - - seems like this might also be a legitimate solution, but I'm not sure - how to make PyQt4 and pyside look the same... - http://stackoverflow.com/a/8717832 - """ + def getvalue(self): + return ''.join(map(asUnicode, self.data)).encode('utf8') + + +def _loadUiType(uiFile): + """ + PySide lacks a "loadUiType" command like PyQt4's, so we have to convert + the ui file to py code in-memory first and then execute it in a + special frame to retrieve the form_class. + + from stackoverflow: http://stackoverflow.com/a/14195313/3781327 + + seems like this might also be a legitimate solution, but I'm not sure + how to make PyQt4 and pyside look the same... + http://stackoverflow.com/a/8717832 + """ + + if QT_LIB == "PYSIDE": import pysideuic - import xml.etree.ElementTree as xml - #from io import StringIO - - parsed = xml.parse(uiFile) - widget_class = parsed.find('widget').get('class') - form_class = parsed.find('class').text - + else: + try: + import pyside2uic as pysideuic + except ImportError: + # later vserions of pyside2 have dropped pysideuic; use the uic binary instead. + pysideuic = None + + # get class names from ui file + import xml.etree.ElementTree as xml + parsed = xml.parse(uiFile) + widget_class = parsed.find('widget').get('class') + form_class = parsed.find('class').text + + # convert ui file to python code + if pysideuic is None: + pyside2version = tuple(map(int, PySide2.__version__.split("."))) + if pyside2version >= (5, 14) and pyside2version < (5, 14, 2, 2): + warnings.warn('For UI compilation, it is recommended to upgrade to PySide >= 5.15') + uipy = subprocess.check_output(['pyside2-uic', uiFile]) + else: + o = _StringIO() with open(uiFile, 'r') as f: - o = StringIO() - frame = {} - pysideuic.compileUi(f, o, indent=0) - pyc = compile(o.getvalue(), '', 'exec') - exec(pyc, frame) + uipy = o.getvalue() - #Fetch the base_class and form class based on their type in the xml from designer - form_class = frame['Ui_%s'%form_class] - base_class = eval('QtGui.%s'%widget_class) + # exceute python code + pyc = compile(uipy, '', 'exec') + frame = {} + exec(pyc, frame) - return form_class, base_class + # fetch the base_class and form class based on their type in the xml from designer + form_class = frame['Ui_%s'%form_class] + base_class = eval('QtGui.%s'%widget_class) + + return form_class, base_class -elif QT_LIB == PYQT4: +if QT_LIB == PYSIDE: + from PySide import QtGui, QtCore + + try: + from PySide import QtOpenGL + except ImportError as err: + QtOpenGL = FailedImport(err) + try: + from PySide import QtSvg + except ImportError as err: + QtSvg = FailedImport(err) + + try: + from PySide import QtTest + except ImportError as err: + QtTest = FailedImport(err) + + try: + from PySide import shiboken + isQObjectAlive = shiboken.isValid + except ImportError: + # use approximate version + isQObjectAlive = _isQObjectAlive + + import PySide + VERSION_INFO = 'PySide ' + PySide.__version__ + ' Qt ' + QtCore.__version__ + +elif QT_LIB == PYQT4: from PyQt4 import QtGui, QtCore, uic try: from PyQt4 import QtSvg - except ImportError: - pass + except ImportError as err: + QtSvg = FailedImport(err) try: from PyQt4 import QtOpenGL - except ImportError: - pass + except ImportError as err: + QtOpenGL = FailedImport(err) try: from PyQt4 import QtTest - except ImportError: - pass + except ImportError as err: + QtTest = FailedImport(err) VERSION_INFO = 'PyQt4 ' + QtCore.PYQT_VERSION_STR + ' Qt ' + QtCore.QT_VERSION_STR elif QT_LIB == PYQT5: - # We're using PyQt5 which has a different structure so we're going to use a shim to # recreate the Qt4 structure for Qt5 from PyQt5 import QtGui, QtCore, QtWidgets, uic + + # PyQt5, starting in v5.5, calls qAbort when an exception is raised inside + # a slot. To maintain backward compatibility (and sanity for interactive + # users), we install a global exception hook to override this behavior. + ver = QtCore.PYQT_VERSION_STR.split('.') + if int(ver[1]) >= 5: + if sys.excepthook == sys.__excepthook__: + sys_excepthook = sys.excepthook + def pyqt5_qabort_override(*args, **kwds): + return sys_excepthook(*args, **kwds) + sys.excepthook = pyqt5_qabort_override + try: from PyQt5 import QtSvg - except ImportError: - pass + except ImportError as err: + QtSvg = FailedImport(err) try: from PyQt5 import QtOpenGL - except ImportError: - pass + except ImportError as err: + QtOpenGL = FailedImport(err) try: from PyQt5 import QtTest QtTest.QTest.qWaitForWindowShown = QtTest.QTest.qWaitForWindowExposed + except ImportError as err: + QtTest = FailedImport(err) + + VERSION_INFO = 'PyQt5 ' + QtCore.PYQT_VERSION_STR + ' Qt ' + QtCore.QT_VERSION_STR + +elif QT_LIB == PYSIDE2: + from PySide2 import QtGui, QtCore, QtWidgets + + try: + from PySide2 import QtSvg + except ImportError as err: + QtSvg = FailedImport(err) + try: + from PySide2 import QtOpenGL + except ImportError as err: + QtOpenGL = FailedImport(err) + try: + from PySide2 import QtTest + QtTest.QTest.qWaitForWindowShown = QtTest.QTest.qWaitForWindowExposed + except ImportError as err: + QtTest = FailedImport(err) + + try: + import shiboken2 + isQObjectAlive = shiboken2.isValid except ImportError: - pass + # use approximate version + isQObjectAlive = _isQObjectAlive + import PySide2 + VERSION_INFO = 'PySide2 ' + PySide2.__version__ + ' Qt ' + QtCore.__version__ + +else: + raise ValueError("Invalid Qt lib '%s'" % QT_LIB) - # Re-implement deprecated APIs +# common to PyQt5 and PySide2 +if QT_LIB in [PYQT5, PYSIDE2]: + # We're using Qt5 which has a different structure so we're going to use a shim to + # recreate the Qt4 structure + __QGraphicsItem_scale = QtWidgets.QGraphicsItem.scale def scale(self, *args): @@ -213,29 +298,65 @@ def setResizeMode(self, *args): if o.startswith('Q'): setattr(QtGui, o, getattr(QtWidgets,o) ) - VERSION_INFO = 'PyQt5 ' + QtCore.PYQT_VERSION_STR + ' Qt ' + QtCore.QT_VERSION_STR -else: - raise ValueError("Invalid Qt lib '%s'" % QT_LIB) +# Common to PySide and PySide2 +if QT_LIB in [PYSIDE, PYSIDE2]: + QtVersion = QtCore.__version__ + loadUiType = _loadUiType + + # PySide does not implement qWait + if not isinstance(QtTest, FailedImport): + if not hasattr(QtTest.QTest, 'qWait'): + @staticmethod + def qWait(msec): + start = time.time() + QtGui.QApplication.processEvents() + while time.time() < start + msec * 0.001: + QtGui.QApplication.processEvents() + QtTest.QTest.qWait = qWait + # Common to PyQt4 and 5 -if QT_LIB.startswith('PyQt'): +if QT_LIB in [PYQT4, PYQT5]: + QtVersion = QtCore.QT_VERSION_STR + import sip def isQObjectAlive(obj): return not sip.isdeleted(obj) + loadUiType = uic.loadUiType QtCore.Signal = QtCore.pyqtSignal - -## Make sure we have Qt >= 4.7 -versionReq = [4, 7] +# USE_XXX variables are deprecated USE_PYSIDE = QT_LIB == PYSIDE USE_PYQT4 = QT_LIB == PYQT4 USE_PYQT5 = QT_LIB == PYQT5 -QtVersion = PySide.QtCore.__version__ if QT_LIB == PYSIDE else QtCore.QT_VERSION_STR + + +## Make sure we have Qt >= 4.7 +versionReq = [4, 7] m = re.match(r'(\d+)\.(\d+).*', QtVersion) if m is not None and list(map(int, m.groups())) < versionReq: print(list(map(int, m.groups()))) raise Exception('pyqtgraph requires Qt version >= %d.%d (your version is %s)' % (versionReq[0], versionReq[1], QtVersion)) + + +QAPP = None +def mkQApp(name=None): + """ + Creates new QApplication or returns current instance if existing. + + ============== ======================================================== + **Arguments:** + name (str) Application name, passed to Qt + ============== ======================================================== + """ + global QAPP + QAPP = QtGui.QApplication.instance() + if QAPP is None: + QAPP = QtGui.QApplication(sys.argv or ["pyqtgraph"]) + if name is not None: + QAPP.setApplicationName(name) + return QAPP diff --git a/pyqtgraph/SRTTransform3D.py b/pyqtgraph/SRTTransform3D.py index 9b54843b94..3c4edcc845 100644 --- a/pyqtgraph/SRTTransform3D.py +++ b/pyqtgraph/SRTTransform3D.py @@ -113,7 +113,7 @@ def setRotate(self, angle, axis=(0,0,1)): def setFromMatrix(self, m): """ - Set this transform mased on the elements of *m* + Set this transform based on the elements of *m* The input matrix must be affine AND have no shear, otherwise the conversion will most likely fail. """ diff --git a/pyqtgraph/SignalProxy.py b/pyqtgraph/SignalProxy.py index d36282fa39..46b44887e8 100644 --- a/pyqtgraph/SignalProxy.py +++ b/pyqtgraph/SignalProxy.py @@ -67,11 +67,11 @@ def flush(self): """If there is a signal queued up, send it now.""" if self.args is None or self.block: return False - #self.emit(self.signal, *self.args) - self.sigDelayed.emit(self.args) - self.args = None + args, self.args = self.args, None self.timer.stop() self.lastFlushTime = time() + #self.emit(self.signal, *self.args) + self.sigDelayed.emit(args) return True def disconnect(self): @@ -81,7 +81,7 @@ def disconnect(self): except: pass try: - self.sigDelayed.disconnect(self.slot()) + self.sigDelayed.disconnect(self.slot) except: pass diff --git a/pyqtgraph/Transform3D.py b/pyqtgraph/Transform3D.py index 43b12de305..b5a41bc2dd 100644 --- a/pyqtgraph/Transform3D.py +++ b/pyqtgraph/Transform3D.py @@ -1,13 +1,23 @@ # -*- coding: utf-8 -*- from .Qt import QtCore, QtGui from . import functions as fn +from .Vector import Vector import numpy as np + class Transform3D(QtGui.QMatrix4x4): """ Extension of QMatrix4x4 with some helpful methods added. """ def __init__(self, *args): + if len(args) == 1: + if isinstance(args[0], (list, tuple, np.ndarray)): + args = [x for y in args[0] for x in y] + if len(args) != 16: + raise TypeError("Single argument to Transform3D must have 16 elements.") + elif isinstance(args[0], QtGui.QMatrix4x4): + args = list(args[0].copyDataTo()) + QtGui.QMatrix4x4.__init__(self, *args) def matrix(self, nd=3): @@ -25,11 +35,18 @@ def map(self, obj): """ Extends QMatrix4x4.map() to allow mapping (3, ...) arrays of coordinates """ - if isinstance(obj, np.ndarray) and obj.ndim >= 2 and obj.shape[0] in (2,3): - return fn.transformCoordinates(self, obj) + if isinstance(obj, np.ndarray) and obj.shape[0] in (2,3): + if obj.ndim >= 2: + return fn.transformCoordinates(self, obj) + elif obj.ndim == 1: + v = QtGui.QMatrix4x4.map(self, Vector(obj)) + return np.array([v.x(), v.y(), v.z()])[:obj.shape[0]] + elif isinstance(obj, (list, tuple)): + v = QtGui.QMatrix4x4.map(self, Vector(obj)) + return type(obj)([v.x(), v.y(), v.z()])[:len(obj)] else: return QtGui.QMatrix4x4.map(self, obj) def inverted(self): inv, b = QtGui.QMatrix4x4.inverted(self) - return Transform3D(inv), b \ No newline at end of file + return Transform3D(inv), b diff --git a/pyqtgraph/Vector.py b/pyqtgraph/Vector.py index c4a3142853..f2166c45c5 100644 --- a/pyqtgraph/Vector.py +++ b/pyqtgraph/Vector.py @@ -5,7 +5,7 @@ Distributed under MIT/X11 license. See license.txt for more information. """ -from .Qt import QtGui, QtCore, USE_PYSIDE +from .Qt import QtGui, QtCore, QT_LIB import numpy as np class Vector(QtGui.QVector3D): @@ -36,7 +36,7 @@ def __len__(self): def __add__(self, b): # workaround for pyside bug. see https://bugs.launchpad.net/pyqtgraph/+bug/1223173 - if USE_PYSIDE and isinstance(b, QtGui.QVector3D): + if QT_LIB == 'PySide' and isinstance(b, QtGui.QVector3D): b = Vector(b) return QtGui.QVector3D.__add__(self, b) diff --git a/pyqtgraph/WidgetGroup.py b/pyqtgraph/WidgetGroup.py index 9371bb977f..2792aa98c1 100644 --- a/pyqtgraph/WidgetGroup.py +++ b/pyqtgraph/WidgetGroup.py @@ -8,7 +8,7 @@ of a large group of widgets. """ -from .Qt import QtCore, QtGui, USE_PYQT5 +from .Qt import QtCore, QtGui, QT_LIB import weakref, inspect from .python2_3 import asUnicode @@ -218,7 +218,7 @@ def widgetChanged(self, w, *args): v1 = self.cache[n] v2 = self.readWidget(w) if v1 != v2: - if not USE_PYQT5: + if QT_LIB != 'PyQt5': # Old signal kept for backward compatibility. self.emit(QtCore.SIGNAL('changed'), self.widgetList[w], v2) self.sigChanged.emit(self.widgetList[w], v2) diff --git a/pyqtgraph/__init__.py b/pyqtgraph/__init__.py index 301f9f1efb..bc36e89170 100644 --- a/pyqtgraph/__init__.py +++ b/pyqtgraph/__init__.py @@ -4,13 +4,13 @@ www.pyqtgraph.org """ -__version__ = '0.10.0' +__version__ = '0.11.0' ### import all the goodies and add some helper functions for easy CLI use ## 'Qt' is a local module; it is intended mainly to cover up the differences ## between PyQt4 and PySide. -from .Qt import QtGui +from .Qt import QtGui, mkQApp ## not really safe--If we accidentally create another QApplication, the process hangs (and it is very difficult to trace the cause) #if QtGui.QApplication.instance() is None: @@ -29,9 +29,6 @@ ## helpers for 2/3 compatibility from . import python2_3 -## install workarounds for numpy bugs -from . import numpy_fix - ## in general openGL is poorly supported with Qt+GraphicsView. ## we only enable it where the performance benefit is critical. ## Note this only applies to 2D graphics; 3D graphics always use OpenGL. @@ -67,7 +64,6 @@ def setConfigOption(opt, value): - global CONFIG_OPTIONS if opt not in CONFIG_OPTIONS: raise KeyError('Unknown configuration option "%s"' % opt) if opt == 'imageAxisOrder' and value not in ('row-major', 'col-major'): @@ -99,7 +95,8 @@ def systemInfo(): if __version__ is None: ## this code was probably checked out from bzr; look up the last-revision file lastRevFile = os.path.join(os.path.dirname(__file__), '..', '.bzr', 'branch', 'last-revision') if os.path.exists(lastRevFile): - rev = open(lastRevFile, 'r').read().strip() + with open(lastRevFile, 'r') as fd: + rev = fd.read().strip() print("pyqtgraph: %s; %s" % (__version__, rev)) print("config:") @@ -222,6 +219,7 @@ def renamePyc(startDir): from .graphicsItems.ArrowItem import * from .graphicsItems.ImageItem import * from .graphicsItems.AxisItem import * +from .graphicsItems.DateAxisItem import * from .graphicsItems.LabelItem import * from .graphicsItems.CurvePoint import * from .graphicsItems.GraphicsWidgetAnchor import * @@ -258,10 +256,13 @@ def renamePyc(startDir): from .widgets.FeedbackButton import * from .widgets.ColorButton import * from .widgets.DataTreeWidget import * +from .widgets.DiffTreeWidget import * from .widgets.GraphicsView import * from .widgets.LayoutWidget import * from .widgets.TableWidget import * from .widgets.ProgressDialog import * +from .widgets.GroupBox import GroupBox +from .widgets.RemoteGraphicsView import RemoteGraphicsView from .imageview import * from .WidgetGroup import * @@ -303,7 +304,10 @@ def cleanup(): ## ALL QGraphicsItems must have a scene before they are deleted. ## This is potentially very expensive, but preferred over crashing. ## Note: this appears to be fixed in PySide as of 2012.12, but it should be left in for a while longer.. - if QtGui.QApplication.instance() is None: + app = QtGui.QApplication.instance() + if app is None or not isinstance(app, QtGui.QApplication): + # app was never constructed is already deleted or is an + # QCoreApplication/QGuiApplication and not a full QApplication return import gc s = QtGui.QGraphicsScene() @@ -316,7 +320,7 @@ def cleanup(): 'are properly called before app shutdown (%s)\n' % (o,)) s.addItem(o) - except RuntimeError: ## occurs if a python wrapper no longer has its underlying C++ object + except (RuntimeError, ReferenceError): ## occurs if a python wrapper no longer has its underlying C++ object continue _cleanupCalled = True @@ -364,8 +368,12 @@ def exit(): ## close file handles if sys.platform == 'darwin': for fd in range(3, 4096): - if fd not in [7]: # trying to close 7 produces an illegal instruction on the Mac. + if fd in [7]: # trying to close 7 produces an illegal instruction on the Mac. + continue + try: os.close(fd) + except OSError: + pass else: os.closerange(3, 4096) ## just guessing on the maximum descriptor count.. @@ -405,12 +413,20 @@ def plot(*args, **kargs): dataArgs[k] = kargs[k] w = PlotWindow(**pwArgs) + w.sigClosed.connect(_plotWindowClosed) if len(args) > 0 or len(dataArgs) > 0: w.plot(*args, **dataArgs) plots.append(w) w.show() return w - + +def _plotWindowClosed(w): + w.close() + try: + plots.remove(w) + except ValueError: + pass + def image(*args, **kargs): """ Create and return an :class:`ImageWindow ` @@ -421,11 +437,19 @@ def image(*args, **kargs): """ mkQApp() w = ImageWindow(*args, **kargs) + w.sigClosed.connect(_imageWindowClosed) images.append(w) w.show() return w show = image ## for backward compatibility +def _imageWindowClosed(w): + w.close() + try: + images.remove(w) + except ValueError: + pass + def dbg(*args, **kwds): """ Create a console window and begin watching for exceptions. @@ -443,14 +467,22 @@ def dbg(*args, **kwds): except NameError: consoles = [c] return c + + +def stack(*args, **kwds): + """ + Create a console window and show the current stack trace. - -def mkQApp(): - global QAPP - inst = QtGui.QApplication.instance() - if inst is None: - QAPP = QtGui.QApplication([]) - else: - QAPP = inst - return QAPP - + All arguments are passed to :func:`ConsoleWidget.__init__() `. + """ + mkQApp() + from . import console + c = console.ConsoleWidget(*args, **kwds) + c.setStack() + c.show() + global consoles + try: + consoles.append(c) + except NameError: + consoles = [c] + return c diff --git a/pyqtgraph/canvas/Canvas.py b/pyqtgraph/canvas/Canvas.py index 4de891f795..2ec13b1932 100644 --- a/pyqtgraph/canvas/Canvas.py +++ b/pyqtgraph/canvas/Canvas.py @@ -1,25 +1,27 @@ # -*- coding: utf-8 -*- -if __name__ == '__main__': - import sys, os - md = os.path.dirname(os.path.abspath(__file__)) - sys.path = [os.path.dirname(md), os.path.join(md, '..', '..', '..')] + sys.path -from ..Qt import QtGui, QtCore, USE_PYSIDE +from ..Qt import QtGui, QtCore, QT_LIB from ..graphicsItems.ROI import ROI from ..graphicsItems.ViewBox import ViewBox from ..graphicsItems.GridItem import GridItem -if USE_PYSIDE: +if QT_LIB == 'PySide': from .CanvasTemplate_pyside import * -else: +elif QT_LIB == 'PyQt4': from .CanvasTemplate_pyqt import * +elif QT_LIB == 'PySide2': + from .CanvasTemplate_pyside2 import * +elif QT_LIB == 'PyQt5': + from .CanvasTemplate_pyqt5 import * import numpy as np from .. import debug import weakref +import gc from .CanvasManager import CanvasManager from .CanvasItem import CanvasItem, GroupCanvasItem + class Canvas(QtGui.QWidget): sigSelectionChanged = QtCore.Signal(object, object) @@ -30,7 +32,6 @@ def __init__(self, parent=None, allowTransforms=True, hideCtrl=False, name=None) QtGui.QWidget.__init__(self, parent) self.ui = Ui_Form() self.ui.setupUi(self) - #self.view = self.ui.view self.view = ViewBox() self.ui.view.setCentralItem(self.view) self.itemList = self.ui.itemList @@ -47,9 +48,7 @@ def __init__(self, parent=None, allowTransforms=True, hideCtrl=False, name=None) self.redirect = None ## which canvas to redirect items to self.items = [] - #self.view.enableMouse() self.view.setAspectLocked(True) - #self.view.invertY() grid = GridItem() self.grid = CanvasItem(grid, name='Grid', movable=False) @@ -67,8 +66,6 @@ def __init__(self, parent=None, allowTransforms=True, hideCtrl=False, name=None) self.ui.itemList.sigItemMoved.connect(self.treeItemMoved) self.ui.itemList.itemSelectionChanged.connect(self.treeItemSelected) self.ui.autoRangeBtn.clicked.connect(self.autoRange) - #self.ui.storeSvgBtn.clicked.connect(self.storeSvg) - #self.ui.storePngBtn.clicked.connect(self.storePng) self.ui.redirectCheck.toggled.connect(self.updateRedirect) self.ui.redirectCombo.currentIndexChanged.connect(self.updateRedirect) self.multiSelectBox.sigRegionChanged.connect(self.multiSelectBoxChanged) @@ -86,21 +83,11 @@ def __init__(self, parent=None, allowTransforms=True, hideCtrl=False, name=None) self.ui.redirectCombo.setHostName(self.registeredName) self.menu = QtGui.QMenu() - #self.menu.setTitle("Image") remAct = QtGui.QAction("Remove item", self.menu) remAct.triggered.connect(self.removeClicked) self.menu.addAction(remAct) self.menu.remAct = remAct self.ui.itemList.contextMenuEvent = self.itemListContextMenuEvent - - - #def storeSvg(self): - #from pyqtgraph.GraphicsScene.exportDialog import ExportDialog - #ex = ExportDialog(self.ui.view) - #ex.show() - - #def storePng(self): - #self.ui.view.writeImage() def splitterMoved(self): self.resizeEvent() @@ -133,7 +120,6 @@ def resizeEvent(self, ev=None): s = min(self.width(), max(100, min(200, self.width()*0.25))) s2 = self.width()-s self.ui.splitter.setSizes([s2, s]) - def updateRedirect(self, *args): ### Decide whether/where to redirect items and make it so @@ -152,7 +138,6 @@ def updateRedirect(self, *args): self.reclaimItems() else: self.redirectItems(redirect) - def redirectItems(self, canvas): for i in self.items: @@ -169,12 +154,9 @@ def redirectItems(self, canvas): else: parent.removeChild(li) canvas.addItem(i) - def reclaimItems(self): items = self.items - #self.items = {'Grid': items['Grid']} - #del items['Grid'] self.items = [self.grid] items.remove(self.grid) @@ -183,9 +165,6 @@ def reclaimItems(self): self.addItem(i) def treeItemChanged(self, item, col): - #gi = self.items.get(item.name, None) - #if gi is None: - #return try: citem = item.canvasItem() except AttributeError: @@ -201,25 +180,16 @@ def treeItemChanged(self, item, col): def treeItemSelected(self): sel = self.selectedItems() - #sel = [] - #for listItem in self.itemList.selectedItems(): - #if hasattr(listItem, 'canvasItem') and listItem.canvasItem is not None: - #sel.append(listItem.canvasItem) - #sel = [self.items[item.name] for item in sel] - + if len(sel) == 0: - #self.selectWidget.hide() return multi = len(sel) > 1 for i in self.items: - #i.ctrlWidget().hide() ## updated the selected state of every item i.selectionChanged(i in sel, multi) if len(sel)==1: - #item = sel[0] - #item.ctrlWidget().show() self.multiSelectBox.hide() self.ui.mirrorSelectionBtn.hide() self.ui.reflectSelectionBtn.hide() @@ -227,14 +197,6 @@ def treeItemSelected(self): elif len(sel) > 1: self.showMultiSelectBox() - #if item.isMovable(): - #self.selectBox.setPos(item.item.pos()) - #self.selectBox.setSize(item.item.sceneBoundingRect().size()) - #self.selectBox.show() - #else: - #self.selectBox.hide() - - #self.emit(QtCore.SIGNAL('itemSelected'), self, item) self.sigSelectionChanged.emit(self, sel) def selectedItems(self): @@ -243,19 +205,9 @@ def selectedItems(self): """ return [item.canvasItem() for item in self.itemList.selectedItems() if item.canvasItem() is not None] - #def selectedItem(self): - #sel = self.itemList.selectedItems() - #if sel is None or len(sel) < 1: - #return - #return self.items.get(sel[0].name, None) - def selectItem(self, item): li = item.listItem - #li = self.getListItem(item.name()) - #print "select", li self.itemList.setCurrentItem(li) - - def showMultiSelectBox(self): ## Get list of selected canvas items @@ -279,7 +231,6 @@ def showMultiSelectBox(self): self.ui.mirrorSelectionBtn.show() self.ui.reflectSelectionBtn.show() self.ui.resetTransformsBtn.show() - #self.multiSelectBoxBase = self.multiSelectBox.getState().copy() def mirrorSelectionClicked(self): for ci in self.selectedItems(): @@ -310,7 +261,6 @@ def multiSelectBoxMoved(self): ci.setTemporaryTransform(transform) ci.sigTransformChanged.emit(ci) - def addGraphicsItem(self, item, **opts): """Add a new GraphicsItem to the scene at pos. Common options are name, pos, scale, and z @@ -319,13 +269,11 @@ def addGraphicsItem(self, item, **opts): item._canvasItem = citem self.addItem(citem) return citem - def addGroup(self, name, **kargs): group = GroupCanvasItem(name=name) self.addItem(group, **kargs) return group - def addItem(self, citem): """ @@ -361,7 +309,6 @@ def addItem(self, citem): #name = newname ## find parent and add item to tree - #currentNode = self.itemList.invisibleRootItem() insertLocation = 0 #print "Inserting node:", name @@ -378,7 +325,7 @@ def addItem(self, citem): z = citem.zValue() if z is None: zvals = [i.zValue() for i in siblings] - if parent == self.itemList.invisibleRootItem(): + if parent is self.itemList.invisibleRootItem(): if len(zvals) == 0: z = 0 else: @@ -411,11 +358,7 @@ def addItem(self, citem): node.setCheckState(0, QtCore.Qt.Unchecked) node.name = name - #if citem.opts['parent'] != None: - ## insertLocation is incorrect in this case parent.insertChild(insertLocation, node) - #else: - #root.insertChild(insertLocation, node) citem.name = name citem.listItem = node @@ -433,36 +376,6 @@ def addItem(self, citem): if len(self.items) == 2: self.autoRange() - - #for n in name: - #nextnode = None - #for x in range(currentNode.childCount()): - #ch = currentNode.child(x) - #if hasattr(ch, 'name'): ## check Z-value of current item to determine insert location - #zval = ch.canvasItem.zValue() - #if zval > z: - ###print " ->", x - #insertLocation = x+1 - #if n == ch.text(0): - #nextnode = ch - #break - #if nextnode is None: ## If name doesn't exist, create it - #nextnode = QtGui.QTreeWidgetItem([n]) - #nextnode.setFlags((nextnode.flags() | QtCore.Qt.ItemIsUserCheckable) & ~QtCore.Qt.ItemIsDropEnabled) - #nextnode.setCheckState(0, QtCore.Qt.Checked) - ### Add node to correct position in list by Z-value - ###print " ==>", insertLocation - #currentNode.insertChild(insertLocation, nextnode) - - #if n == name[-1]: ## This is the leaf; add some extra properties. - #nextnode.name = name - - #if n == name[0]: ## This is the root; make the item movable - #nextnode.setFlags(nextnode.flags() | QtCore.Qt.ItemIsDragEnabled) - #else: - #nextnode.setFlags(nextnode.flags() & ~QtCore.Qt.ItemIsDragEnabled) - - #currentNode = nextnode return citem def treeItemMoved(self, item, parent, index): @@ -479,31 +392,6 @@ def treeItemMoved(self, item, parent, index): for i in range(len(siblings)): item = siblings[i] item.setZValue(zvals[i]) - #item = self.itemList.topLevelItem(i) - - ##ci = self.items[item.name] - #ci = item.canvasItem - #if ci is None: - #continue - #if ci.zValue() != zvals[i]: - #ci.setZValue(zvals[i]) - - #if self.itemList.topLevelItemCount() < 2: - #return - #name = item.name - #gi = self.items[name] - #if index == 0: - #next = self.itemList.topLevelItem(1) - #z = self.items[next.name].zValue()+1 - #else: - #prev = self.itemList.topLevelItem(index-1) - #z = self.items[prev.name].zValue()-1 - #gi.setZValue(z) - - - - - def itemVisibilityChanged(self, item): listItem = item.listItem @@ -519,7 +407,6 @@ def removeItem(self, item): if isinstance(item, QtGui.QTreeWidgetItem): item = item.canvasItem() - if isinstance(item, CanvasItem): item.setCanvas(None) listItem = item.listItem @@ -530,25 +417,24 @@ def removeItem(self, item): ctrl = item.ctrlWidget() ctrl.hide() self.ui.ctrlLayout.removeWidget(ctrl) + ctrl.setParent(None) else: if hasattr(item, '_canvasItem'): self.removeItem(item._canvasItem) else: self.view.removeItem(item) - - ## disconnect signals, remove from list, etc.. + + gc.collect() def clear(self): while len(self.items) > 0: self.removeItem(self.items[0]) - def addToScene(self, item): self.view.addItem(item) def removeFromScene(self, item): self.view.removeItem(item) - def listItems(self): """Return a dictionary of name:item pairs""" @@ -557,15 +443,10 @@ def listItems(self): def getListItem(self, name): return self.items[name] - #def scene(self): - #return self.view.scene() - def itemTransformChanged(self, item): - #self.emit(QtCore.SIGNAL('itemTransformChanged'), self, item) self.sigItemTransformChanged.emit(self, item) def itemTransformChangeFinished(self, item): - #self.emit(QtCore.SIGNAL('itemTransformChangeFinished'), self, item) self.sigItemTransformChangeFinished.emit(self, item) def itemListContextMenuEvent(self, ev): @@ -573,13 +454,13 @@ def itemListContextMenuEvent(self, ev): self.menu.popup(ev.globalPos()) def removeClicked(self): - #self.removeItem(self.menuItem) for item in self.selectedItems(): self.removeItem(item) self.menuItem = None import gc gc.collect() + class SelectBox(ROI): def __init__(self, scalable=False): #QtGui.QGraphicsRectItem.__init__(self, 0, 0, size[0], size[1]) @@ -591,14 +472,3 @@ def __init__(self, scalable=False): self.addScaleHandle([0, 0], center, lockAspect=True) self.addRotateHandle([0, 1], center) self.addRotateHandle([1, 0], center) - - - - - - - - - - - diff --git a/pyqtgraph/canvas/CanvasItem.py b/pyqtgraph/canvas/CanvasItem.py index b6ecbb396b..57174b5f8d 100644 --- a/pyqtgraph/canvas/CanvasItem.py +++ b/pyqtgraph/canvas/CanvasItem.py @@ -1,11 +1,16 @@ # -*- coding: utf-8 -*- -from ..Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +import numpy as np +from ..Qt import QtGui, QtCore, QtSvg, QT_LIB from ..graphicsItems.ROI import ROI from .. import SRTTransform, ItemGroup -if USE_PYSIDE: +if QT_LIB == 'PySide': from . import TransformGuiTemplate_pyside as TransformGuiTemplate -else: +elif QT_LIB == 'PyQt4': from . import TransformGuiTemplate_pyqt as TransformGuiTemplate +elif QT_LIB == 'PySide2': + from . import TransformGuiTemplate_pyside2 as TransformGuiTemplate +elif QT_LIB == 'PyQt5': + from . import TransformGuiTemplate_pyqt5 as TransformGuiTemplate from .. import debug @@ -85,14 +90,12 @@ def __init__(self, item, **opts): self.alphaSlider.valueChanged.connect(self.alphaChanged) self.alphaSlider.sliderPressed.connect(self.alphaPressed) self.alphaSlider.sliderReleased.connect(self.alphaReleased) - #self.canvas.sigSelectionChanged.connect(self.selectionChanged) self.resetTransformBtn.clicked.connect(self.resetTransformClicked) self.copyBtn.clicked.connect(self.copyClicked) self.pasteBtn.clicked.connect(self.pasteClicked) self.setMovable(self.opts['movable']) ## update gui to reflect this option - if 'transform' in self.opts: self.baseTransform = self.opts['transform'] else: @@ -112,7 +115,6 @@ def __init__(self, item, **opts): ## every CanvasItem implements its own individual selection box ## so that subclasses are free to make their own. self.selectBox = SelectBox(scalable=self.opts['scalable'], rotatable=self.opts['rotatable']) - #self.canvas.scene().addItem(self.selectBox) self.selectBox.hide() self.selectBox.setZValue(1e6) self.selectBox.sigRegionChanged.connect(self.selectBoxChanged) ## calls selectBoxMoved @@ -127,16 +129,7 @@ def __init__(self, item, **opts): self.tempTransform = SRTTransform() ## holds the additional transform that happens during a move - gets added to the userTransform when move is done. self.userTransform = SRTTransform() ## stores the total transform of the object self.resetUserTransform() - - ## now happens inside resetUserTransform -> selectBoxToItem - # self.selectBoxBase = self.selectBox.getState().copy() - - - #print "Created canvas item", self - #print " base:", self.baseTransform - #print " user:", self.userTransform - #print " temp:", self.tempTransform - #print " bounds:", self.item.sceneBoundingRect() + def setMovable(self, m): self.opts['movable'] = m @@ -237,7 +230,6 @@ def mirrorXY(self): # s=self.updateTransform() # self.setTranslate(-2*s['pos'][0], -2*s['pos'][1]) # self.selectBoxFromUser() - def hasUserTransform(self): #print self.userRotate, self.userTranslate @@ -250,10 +242,15 @@ def alphaChanged(self, val): alpha = val / 1023. self._graphicsItem.setOpacity(alpha) + def setAlpha(self, alpha): + self.alphaSlider.setValue(int(np.clip(alpha * 1023, 0, 1023))) + + def alpha(self): + return self.alphaSlider.value() / 1023. + def isMovable(self): return self.opts['movable'] - def selectBoxMoved(self): """The selection box has moved; get its transformation information and pass to the graphics item""" self.userTransform = self.selectBox.getGlobalTransform(relativeTo=self.selectBoxBase) @@ -288,7 +285,6 @@ def setScale(self, x, y): self.userTransform.setScale(x, y) self.selectBoxFromUser() self.updateTransform() - def setTemporaryTransform(self, transform): self.tempTransform = transform @@ -300,21 +296,6 @@ def applyTemporaryTransform(self): self.resetTemporaryTransform() self.selectBoxFromUser() ## update the selection box to match the new userTransform - #st = self.userTransform.saveState() - - #self.userTransform = self.userTransform * self.tempTransform ## order is important! - - #### matrix multiplication affects the scale factors, need to reset - #if st['scale'][0] < 0 or st['scale'][1] < 0: - #nst = self.userTransform.saveState() - #self.userTransform.setScale([-nst['scale'][0], -nst['scale'][1]]) - - #self.resetTemporaryTransform() - #self.selectBoxFromUser() - #self.selectBoxChangeFinished() - - - def resetTemporaryTransform(self): self.tempTransform = SRTTransform() ## don't use Transform.reset()--this transform might be used elsewhere. self.updateTransform() @@ -337,20 +318,13 @@ def updateTransform(self): def displayTransform(self, transform): """Updates transform numbers in the ctrl widget.""" - tr = transform.saveState() self.transformGui.translateLabel.setText("Translate: (%f, %f)" %(tr['pos'][0], tr['pos'][1])) self.transformGui.rotateLabel.setText("Rotate: %f degrees" %tr['angle']) self.transformGui.scaleLabel.setText("Scale: (%f, %f)" %(tr['scale'][0], tr['scale'][1])) - #self.transformGui.mirrorImageCheck.setChecked(False) - #if tr['scale'][0] < 0: - # self.transformGui.mirrorImageCheck.setChecked(True) - def resetUserTransform(self): - #self.userRotate = 0 - #self.userTranslate = pg.Point(0,0) self.userTransform.reset() self.updateTransform() @@ -366,8 +340,6 @@ def resetTransformClicked(self): def restoreTransform(self, tr): try: - #self.userTranslate = pg.Point(tr['trans']) - #self.userRotate = tr['rot'] self.userTransform = SRTTransform(tr) self.updateTransform() @@ -375,16 +347,11 @@ def restoreTransform(self, tr): self.sigTransformChanged.emit(self) self.sigTransformChangeFinished.emit(self) except: - #self.userTranslate = pg.Point([0,0]) - #self.userRotate = 0 self.userTransform = SRTTransform() debug.printExc("Failed to load transform:") - #print "set transform", self, self.userTranslate def saveTransform(self): """Return a dict containing the current user transform""" - #print "save transform", self, self.userTranslate - #return {'trans': list(self.userTranslate), 'rot': self.userRotate} return self.userTransform.saveState() def selectBoxFromUser(self): @@ -402,7 +369,6 @@ def selectBoxFromUser(self): #self.selectBox.setAngle(self.userRotate) #self.selectBox.setPos([x2, y2]) self.selectBox.blockSignals(False) - def selectBoxToItem(self): """Move/scale the selection box so it fits the item's bounding rect. (assumes item is not rotated)""" @@ -422,11 +388,6 @@ def setZValue(self, z): self.opts['z'] = z if z is not None: self._graphicsItem.setZValue(z) - - #def selectionChanged(self, canvas, items): - #self.selected = len(items) == 1 and (items[0] is self) - #self.showSelectBox() - def selectionChanged(self, sel, multi): """ @@ -454,16 +415,12 @@ def showSelectBox(self): def hideSelectBox(self): self.selectBox.hide() - def selectBoxChanged(self): self.selectBoxMoved() - #self.updateTransform(self.selectBox) - #self.emit(QtCore.SIGNAL('transformChanged'), self) self.sigTransformChanged.emit(self) def selectBoxChangeFinished(self): - #self.emit(QtCore.SIGNAL('transformChangeFinished'), self) self.sigTransformChangeFinished.emit(self) def alphaPressed(self): @@ -498,6 +455,25 @@ def setVisible(self, vis): def isVisible(self): return self.opts['visible'] + def saveState(self): + return { + 'type': self.__class__.__name__, + 'name': self.name, + 'visible': self.isVisible(), + 'alpha': self.alpha(), + 'userTransform': self.saveTransform(), + 'z': self.zValue(), + 'scalable': self.opts['scalable'], + 'rotatable': self.opts['rotatable'], + 'movable': self.opts['movable'], + } + + def restoreState(self, state): + self.setVisible(state['visible']) + self.setAlpha(state['alpha']) + self.restoreTransform(state['userTransform']) + self.setZValue(state['z']) + class GroupCanvasItem(CanvasItem): """ diff --git a/pyqtgraph/canvas/CanvasTemplate.ui b/pyqtgraph/canvas/CanvasTemplate.ui index 9bea8f8921..15fdf7a906 100644 --- a/pyqtgraph/canvas/CanvasTemplate.ui +++ b/pyqtgraph/canvas/CanvasTemplate.ui @@ -6,14 +6,14 @@ 0 0 - 490 - 414 + 821 + 578 - Form + PyQtGraph - + 0 @@ -26,88 +26,96 @@ Qt::Horizontal - - - - - - - 0 - 1 - - - - Auto Range - - - - - - - 0 - - - - - Check to display all local items in a remote canvas. - + + + Qt::Vertical + + + + + + + + 0 + 1 + + + + Auto Range + + + + + + + 0 + + + + + Check to display all local items in a remote canvas. + + + Redirect + + + + + + + + + + + + + 0 + 100 + + + + true + + - Redirect + 1 - - - - - - - - - - - - 0 - 100 - - - - true - - + + + + + + + Reset Transforms + + + + + + + Mirror Selection + + + + + - 1 + MirrorXY - - - - - - - 0 - - - - - - - Reset Transforms - - - - - - - Mirror Selection - - - - - - - MirrorXY - - - - + + + + + + + + 0 + + + 0 + + +
@@ -127,7 +135,7 @@ CanvasCombo QComboBox -
CanvasManager
+
.CanvasManager
diff --git a/pyqtgraph/canvas/CanvasTemplate_pyqt.py b/pyqtgraph/canvas/CanvasTemplate_pyqt.py index 557354e0a8..823265aaac 100644 --- a/pyqtgraph/canvas/CanvasTemplate_pyqt.py +++ b/pyqtgraph/canvas/CanvasTemplate_pyqt.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'acq4/pyqtgraph/canvas/CanvasTemplate.ui' +# Form implementation generated from reading ui file 'CanvasTemplate.ui' # -# Created: Thu Jan 2 11:13:07 2014 -# by: PyQt4 UI code generator 4.9 +# Created by: PyQt4 UI code generator 4.11.4 # # WARNING! All changes made in this file will be lost! @@ -12,45 +11,56 @@ try: _fromUtf8 = QtCore.QString.fromUtf8 except AttributeError: - _fromUtf8 = lambda s: s + def _fromUtf8(s): + return s + +try: + _encoding = QtGui.QApplication.UnicodeUTF8 + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig, _encoding) +except AttributeError: + def _translate(context, text, disambig): + return QtGui.QApplication.translate(context, text, disambig) class Ui_Form(object): def setupUi(self, Form): Form.setObjectName(_fromUtf8("Form")) - Form.resize(490, 414) - self.gridLayout = QtGui.QGridLayout(Form) - self.gridLayout.setMargin(0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName(_fromUtf8("gridLayout")) + Form.resize(821, 578) + self.gridLayout_2 = QtGui.QGridLayout(Form) + self.gridLayout_2.setMargin(0) + self.gridLayout_2.setSpacing(0) + self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) self.splitter = QtGui.QSplitter(Form) self.splitter.setOrientation(QtCore.Qt.Horizontal) self.splitter.setObjectName(_fromUtf8("splitter")) self.view = GraphicsView(self.splitter) self.view.setObjectName(_fromUtf8("view")) - self.layoutWidget = QtGui.QWidget(self.splitter) - self.layoutWidget.setObjectName(_fromUtf8("layoutWidget")) - self.gridLayout_2 = QtGui.QGridLayout(self.layoutWidget) - self.gridLayout_2.setMargin(0) - self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) - self.autoRangeBtn = QtGui.QPushButton(self.layoutWidget) + self.vsplitter = QtGui.QSplitter(self.splitter) + self.vsplitter.setOrientation(QtCore.Qt.Vertical) + self.vsplitter.setObjectName(_fromUtf8("vsplitter")) + self.canvasCtrlWidget = QtGui.QWidget(self.vsplitter) + self.canvasCtrlWidget.setObjectName(_fromUtf8("canvasCtrlWidget")) + self.gridLayout = QtGui.QGridLayout(self.canvasCtrlWidget) + self.gridLayout.setObjectName(_fromUtf8("gridLayout")) + self.autoRangeBtn = QtGui.QPushButton(self.canvasCtrlWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) self.autoRangeBtn.setSizePolicy(sizePolicy) self.autoRangeBtn.setObjectName(_fromUtf8("autoRangeBtn")) - self.gridLayout_2.addWidget(self.autoRangeBtn, 2, 0, 1, 2) + self.gridLayout.addWidget(self.autoRangeBtn, 0, 0, 1, 2) self.horizontalLayout = QtGui.QHBoxLayout() self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) - self.redirectCheck = QtGui.QCheckBox(self.layoutWidget) + self.redirectCheck = QtGui.QCheckBox(self.canvasCtrlWidget) self.redirectCheck.setObjectName(_fromUtf8("redirectCheck")) self.horizontalLayout.addWidget(self.redirectCheck) - self.redirectCombo = CanvasCombo(self.layoutWidget) + self.redirectCombo = CanvasCombo(self.canvasCtrlWidget) self.redirectCombo.setObjectName(_fromUtf8("redirectCombo")) self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout_2.addLayout(self.horizontalLayout, 5, 0, 1, 2) - self.itemList = TreeWidget(self.layoutWidget) + self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 2) + self.itemList = TreeWidget(self.canvasCtrlWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(100) @@ -59,34 +69,36 @@ def setupUi(self, Form): self.itemList.setHeaderHidden(True) self.itemList.setObjectName(_fromUtf8("itemList")) self.itemList.headerItem().setText(0, _fromUtf8("1")) - self.gridLayout_2.addWidget(self.itemList, 6, 0, 1, 2) - self.ctrlLayout = QtGui.QGridLayout() - self.ctrlLayout.setSpacing(0) - self.ctrlLayout.setObjectName(_fromUtf8("ctrlLayout")) - self.gridLayout_2.addLayout(self.ctrlLayout, 10, 0, 1, 2) - self.resetTransformsBtn = QtGui.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.itemList, 2, 0, 1, 2) + self.resetTransformsBtn = QtGui.QPushButton(self.canvasCtrlWidget) self.resetTransformsBtn.setObjectName(_fromUtf8("resetTransformsBtn")) - self.gridLayout_2.addWidget(self.resetTransformsBtn, 7, 0, 1, 1) - self.mirrorSelectionBtn = QtGui.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.resetTransformsBtn, 3, 0, 1, 2) + self.mirrorSelectionBtn = QtGui.QPushButton(self.canvasCtrlWidget) self.mirrorSelectionBtn.setObjectName(_fromUtf8("mirrorSelectionBtn")) - self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 3, 0, 1, 1) - self.reflectSelectionBtn = QtGui.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) + self.reflectSelectionBtn = QtGui.QPushButton(self.canvasCtrlWidget) self.reflectSelectionBtn.setObjectName(_fromUtf8("reflectSelectionBtn")) - self.gridLayout_2.addWidget(self.reflectSelectionBtn, 3, 1, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) + self.gridLayout.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) + self.canvasItemCtrl = QtGui.QWidget(self.vsplitter) + self.canvasItemCtrl.setObjectName(_fromUtf8("canvasItemCtrl")) + self.ctrlLayout = QtGui.QGridLayout(self.canvasItemCtrl) + self.ctrlLayout.setMargin(0) + self.ctrlLayout.setSpacing(0) + self.ctrlLayout.setObjectName(_fromUtf8("ctrlLayout")) + self.gridLayout_2.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.autoRangeBtn.setText(QtGui.QApplication.translate("Form", "Auto Range", None, QtGui.QApplication.UnicodeUTF8)) - self.redirectCheck.setToolTip(QtGui.QApplication.translate("Form", "Check to display all local items in a remote canvas.", None, QtGui.QApplication.UnicodeUTF8)) - self.redirectCheck.setText(QtGui.QApplication.translate("Form", "Redirect", None, QtGui.QApplication.UnicodeUTF8)) - self.resetTransformsBtn.setText(QtGui.QApplication.translate("Form", "Reset Transforms", None, QtGui.QApplication.UnicodeUTF8)) - self.mirrorSelectionBtn.setText(QtGui.QApplication.translate("Form", "Mirror Selection", None, QtGui.QApplication.UnicodeUTF8)) - self.reflectSelectionBtn.setText(QtGui.QApplication.translate("Form", "MirrorXY", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) + self.autoRangeBtn.setText(_translate("Form", "Auto Range", None)) + self.redirectCheck.setToolTip(_translate("Form", "Check to display all local items in a remote canvas.", None)) + self.redirectCheck.setText(_translate("Form", "Redirect", None)) + self.resetTransformsBtn.setText(_translate("Form", "Reset Transforms", None)) + self.mirrorSelectionBtn.setText(_translate("Form", "Mirror Selection", None)) + self.reflectSelectionBtn.setText(_translate("Form", "MirrorXY", None)) -from ..widgets.TreeWidget import TreeWidget -from CanvasManager import CanvasCombo from ..widgets.GraphicsView import GraphicsView +from ..widgets.TreeWidget import TreeWidget +from .CanvasManager import CanvasCombo diff --git a/pyqtgraph/canvas/CanvasTemplate_pyqt5.py b/pyqtgraph/canvas/CanvasTemplate_pyqt5.py index 13b0c83cb2..83afc7727c 100644 --- a/pyqtgraph/canvas/CanvasTemplate_pyqt5.py +++ b/pyqtgraph/canvas/CanvasTemplate_pyqt5.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/canvas/CanvasTemplate.ui' +# Form implementation generated from reading ui file 'CanvasTemplate.ui' # -# Created: Wed Mar 26 15:09:28 2014 -# by: PyQt5 UI code generator 5.0.1 +# Created by: PyQt5 UI code generator 5.7.1 # # WARNING! All changes made in this file will be lost! @@ -12,46 +11,43 @@ class Ui_Form(object): def setupUi(self, Form): Form.setObjectName("Form") - Form.resize(490, 414) - self.gridLayout = QtWidgets.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName("gridLayout") + Form.resize(821, 578) + self.gridLayout_2 = QtWidgets.QGridLayout(Form) + self.gridLayout_2.setContentsMargins(0, 0, 0, 0) + self.gridLayout_2.setSpacing(0) + self.gridLayout_2.setObjectName("gridLayout_2") self.splitter = QtWidgets.QSplitter(Form) self.splitter.setOrientation(QtCore.Qt.Horizontal) self.splitter.setObjectName("splitter") self.view = GraphicsView(self.splitter) self.view.setObjectName("view") - self.layoutWidget = QtWidgets.QWidget(self.splitter) - self.layoutWidget.setObjectName("layoutWidget") - self.gridLayout_2 = QtWidgets.QGridLayout(self.layoutWidget) - self.gridLayout_2.setContentsMargins(0, 0, 0, 0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.storeSvgBtn = QtWidgets.QPushButton(self.layoutWidget) - self.storeSvgBtn.setObjectName("storeSvgBtn") - self.gridLayout_2.addWidget(self.storeSvgBtn, 1, 0, 1, 1) - self.storePngBtn = QtWidgets.QPushButton(self.layoutWidget) - self.storePngBtn.setObjectName("storePngBtn") - self.gridLayout_2.addWidget(self.storePngBtn, 1, 1, 1, 1) - self.autoRangeBtn = QtWidgets.QPushButton(self.layoutWidget) + self.vsplitter = QtWidgets.QSplitter(self.splitter) + self.vsplitter.setOrientation(QtCore.Qt.Vertical) + self.vsplitter.setObjectName("vsplitter") + self.canvasCtrlWidget = QtWidgets.QWidget(self.vsplitter) + self.canvasCtrlWidget.setObjectName("canvasCtrlWidget") + self.gridLayout = QtWidgets.QGridLayout(self.canvasCtrlWidget) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setObjectName("gridLayout") + self.autoRangeBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) self.autoRangeBtn.setSizePolicy(sizePolicy) self.autoRangeBtn.setObjectName("autoRangeBtn") - self.gridLayout_2.addWidget(self.autoRangeBtn, 3, 0, 1, 2) + self.gridLayout.addWidget(self.autoRangeBtn, 0, 0, 1, 2) self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName("horizontalLayout") - self.redirectCheck = QtWidgets.QCheckBox(self.layoutWidget) + self.redirectCheck = QtWidgets.QCheckBox(self.canvasCtrlWidget) self.redirectCheck.setObjectName("redirectCheck") self.horizontalLayout.addWidget(self.redirectCheck) - self.redirectCombo = CanvasCombo(self.layoutWidget) + self.redirectCombo = CanvasCombo(self.canvasCtrlWidget) self.redirectCombo.setObjectName("redirectCombo") self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout_2.addLayout(self.horizontalLayout, 6, 0, 1, 2) - self.itemList = TreeWidget(self.layoutWidget) + self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 2) + self.itemList = TreeWidget(self.canvasCtrlWidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(100) @@ -60,30 +56,30 @@ def setupUi(self, Form): self.itemList.setHeaderHidden(True) self.itemList.setObjectName("itemList") self.itemList.headerItem().setText(0, "1") - self.gridLayout_2.addWidget(self.itemList, 7, 0, 1, 2) - self.ctrlLayout = QtWidgets.QGridLayout() - self.ctrlLayout.setSpacing(0) - self.ctrlLayout.setObjectName("ctrlLayout") - self.gridLayout_2.addLayout(self.ctrlLayout, 11, 0, 1, 2) - self.resetTransformsBtn = QtWidgets.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.itemList, 2, 0, 1, 2) + self.resetTransformsBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) self.resetTransformsBtn.setObjectName("resetTransformsBtn") - self.gridLayout_2.addWidget(self.resetTransformsBtn, 8, 0, 1, 1) - self.mirrorSelectionBtn = QtWidgets.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.resetTransformsBtn, 3, 0, 1, 2) + self.mirrorSelectionBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) self.mirrorSelectionBtn.setObjectName("mirrorSelectionBtn") - self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) - self.reflectSelectionBtn = QtWidgets.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) + self.reflectSelectionBtn = QtWidgets.QPushButton(self.canvasCtrlWidget) self.reflectSelectionBtn.setObjectName("reflectSelectionBtn") - self.gridLayout_2.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) + self.gridLayout.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) + self.canvasItemCtrl = QtWidgets.QWidget(self.vsplitter) + self.canvasItemCtrl.setObjectName("canvasItemCtrl") + self.ctrlLayout = QtWidgets.QGridLayout(self.canvasItemCtrl) + self.ctrlLayout.setContentsMargins(0, 0, 0, 0) + self.ctrlLayout.setSpacing(0) + self.ctrlLayout.setObjectName("ctrlLayout") + self.gridLayout_2.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) - self.storeSvgBtn.setText(_translate("Form", "Store SVG")) - self.storePngBtn.setText(_translate("Form", "Store PNG")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.autoRangeBtn.setText(_translate("Form", "Auto Range")) self.redirectCheck.setToolTip(_translate("Form", "Check to display all local items in a remote canvas.")) self.redirectCheck.setText(_translate("Form", "Redirect")) @@ -93,4 +89,4 @@ def retranslateUi(self, Form): from ..widgets.GraphicsView import GraphicsView from ..widgets.TreeWidget import TreeWidget -from CanvasManager import CanvasCombo +from .CanvasManager import CanvasCombo diff --git a/pyqtgraph/canvas/CanvasTemplate_pyside.py b/pyqtgraph/canvas/CanvasTemplate_pyside.py index 56d1ff47cb..c728efacec 100644 --- a/pyqtgraph/canvas/CanvasTemplate_pyside.py +++ b/pyqtgraph/canvas/CanvasTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/canvas/CanvasTemplate.ui' +# Form implementation generated from reading ui file 'CanvasTemplate.ui' # -# Created: Mon Dec 23 10:10:52 2013 -# by: pyside-uic 0.2.14 running on PySide 1.1.2 +# Created: Fri Mar 24 16:09:39 2017 +# by: pyside-uic 0.2.15 running on PySide 1.2.2 # # WARNING! All changes made in this file will be lost! @@ -12,46 +12,43 @@ class Ui_Form(object): def setupUi(self, Form): Form.setObjectName("Form") - Form.resize(490, 414) - self.gridLayout = QtGui.QGridLayout(Form) - self.gridLayout.setContentsMargins(0, 0, 0, 0) - self.gridLayout.setSpacing(0) - self.gridLayout.setObjectName("gridLayout") + Form.resize(821, 578) + self.gridLayout_2 = QtGui.QGridLayout(Form) + self.gridLayout_2.setContentsMargins(0, 0, 0, 0) + self.gridLayout_2.setSpacing(0) + self.gridLayout_2.setObjectName("gridLayout_2") self.splitter = QtGui.QSplitter(Form) self.splitter.setOrientation(QtCore.Qt.Horizontal) self.splitter.setObjectName("splitter") self.view = GraphicsView(self.splitter) self.view.setObjectName("view") - self.layoutWidget = QtGui.QWidget(self.splitter) - self.layoutWidget.setObjectName("layoutWidget") - self.gridLayout_2 = QtGui.QGridLayout(self.layoutWidget) - self.gridLayout_2.setContentsMargins(0, 0, 0, 0) - self.gridLayout_2.setObjectName("gridLayout_2") - self.storeSvgBtn = QtGui.QPushButton(self.layoutWidget) - self.storeSvgBtn.setObjectName("storeSvgBtn") - self.gridLayout_2.addWidget(self.storeSvgBtn, 1, 0, 1, 1) - self.storePngBtn = QtGui.QPushButton(self.layoutWidget) - self.storePngBtn.setObjectName("storePngBtn") - self.gridLayout_2.addWidget(self.storePngBtn, 1, 1, 1, 1) - self.autoRangeBtn = QtGui.QPushButton(self.layoutWidget) + self.vsplitter = QtGui.QSplitter(self.splitter) + self.vsplitter.setOrientation(QtCore.Qt.Vertical) + self.vsplitter.setObjectName("vsplitter") + self.canvasCtrlWidget = QtGui.QWidget(self.vsplitter) + self.canvasCtrlWidget.setObjectName("canvasCtrlWidget") + self.gridLayout = QtGui.QGridLayout(self.canvasCtrlWidget) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setObjectName("gridLayout") + self.autoRangeBtn = QtGui.QPushButton(self.canvasCtrlWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) self.autoRangeBtn.setSizePolicy(sizePolicy) self.autoRangeBtn.setObjectName("autoRangeBtn") - self.gridLayout_2.addWidget(self.autoRangeBtn, 3, 0, 1, 2) + self.gridLayout.addWidget(self.autoRangeBtn, 0, 0, 1, 2) self.horizontalLayout = QtGui.QHBoxLayout() self.horizontalLayout.setSpacing(0) self.horizontalLayout.setObjectName("horizontalLayout") - self.redirectCheck = QtGui.QCheckBox(self.layoutWidget) + self.redirectCheck = QtGui.QCheckBox(self.canvasCtrlWidget) self.redirectCheck.setObjectName("redirectCheck") self.horizontalLayout.addWidget(self.redirectCheck) - self.redirectCombo = CanvasCombo(self.layoutWidget) + self.redirectCombo = CanvasCombo(self.canvasCtrlWidget) self.redirectCombo.setObjectName("redirectCombo") self.horizontalLayout.addWidget(self.redirectCombo) - self.gridLayout_2.addLayout(self.horizontalLayout, 6, 0, 1, 2) - self.itemList = TreeWidget(self.layoutWidget) + self.gridLayout.addLayout(self.horizontalLayout, 1, 0, 1, 2) + self.itemList = TreeWidget(self.canvasCtrlWidget) sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(100) @@ -60,29 +57,30 @@ def setupUi(self, Form): self.itemList.setHeaderHidden(True) self.itemList.setObjectName("itemList") self.itemList.headerItem().setText(0, "1") - self.gridLayout_2.addWidget(self.itemList, 7, 0, 1, 2) - self.ctrlLayout = QtGui.QGridLayout() - self.ctrlLayout.setSpacing(0) - self.ctrlLayout.setObjectName("ctrlLayout") - self.gridLayout_2.addLayout(self.ctrlLayout, 11, 0, 1, 2) - self.resetTransformsBtn = QtGui.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.itemList, 2, 0, 1, 2) + self.resetTransformsBtn = QtGui.QPushButton(self.canvasCtrlWidget) self.resetTransformsBtn.setObjectName("resetTransformsBtn") - self.gridLayout_2.addWidget(self.resetTransformsBtn, 8, 0, 1, 1) - self.mirrorSelectionBtn = QtGui.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.resetTransformsBtn, 3, 0, 1, 2) + self.mirrorSelectionBtn = QtGui.QPushButton(self.canvasCtrlWidget) self.mirrorSelectionBtn.setObjectName("mirrorSelectionBtn") - self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) - self.reflectSelectionBtn = QtGui.QPushButton(self.layoutWidget) + self.gridLayout.addWidget(self.mirrorSelectionBtn, 4, 0, 1, 1) + self.reflectSelectionBtn = QtGui.QPushButton(self.canvasCtrlWidget) self.reflectSelectionBtn.setObjectName("reflectSelectionBtn") - self.gridLayout_2.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) - self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) + self.gridLayout.addWidget(self.reflectSelectionBtn, 4, 1, 1, 1) + self.canvasItemCtrl = QtGui.QWidget(self.vsplitter) + self.canvasItemCtrl.setObjectName("canvasItemCtrl") + self.ctrlLayout = QtGui.QGridLayout(self.canvasItemCtrl) + self.ctrlLayout.setContentsMargins(0, 0, 0, 0) + self.ctrlLayout.setSpacing(0) + self.ctrlLayout.setContentsMargins(0, 0, 0, 0) + self.ctrlLayout.setObjectName("ctrlLayout") + self.gridLayout_2.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) - self.storeSvgBtn.setText(QtGui.QApplication.translate("Form", "Store SVG", None, QtGui.QApplication.UnicodeUTF8)) - self.storePngBtn.setText(QtGui.QApplication.translate("Form", "Store PNG", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.autoRangeBtn.setText(QtGui.QApplication.translate("Form", "Auto Range", None, QtGui.QApplication.UnicodeUTF8)) self.redirectCheck.setToolTip(QtGui.QApplication.translate("Form", "Check to display all local items in a remote canvas.", None, QtGui.QApplication.UnicodeUTF8)) self.redirectCheck.setText(QtGui.QApplication.translate("Form", "Redirect", None, QtGui.QApplication.UnicodeUTF8)) @@ -90,6 +88,6 @@ def retranslateUi(self, Form): self.mirrorSelectionBtn.setText(QtGui.QApplication.translate("Form", "Mirror Selection", None, QtGui.QApplication.UnicodeUTF8)) self.reflectSelectionBtn.setText(QtGui.QApplication.translate("Form", "MirrorXY", None, QtGui.QApplication.UnicodeUTF8)) +from .CanvasManager import CanvasCombo from ..widgets.TreeWidget import TreeWidget -from CanvasManager import CanvasCombo from ..widgets.GraphicsView import GraphicsView diff --git a/pyqtgraph/canvas/CanvasTemplate_pyside2.py b/pyqtgraph/canvas/CanvasTemplate_pyside2.py new file mode 100644 index 0000000000..de9c63223c --- /dev/null +++ b/pyqtgraph/canvas/CanvasTemplate_pyside2.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'CanvasTemplate.ui' +# +# Created: Sun Sep 18 19:18:22 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(490, 414) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setSpacing(0) + self.gridLayout.setObjectName("gridLayout") + self.splitter = QtWidgets.QSplitter(Form) + self.splitter.setOrientation(QtCore.Qt.Horizontal) + self.splitter.setObjectName("splitter") + self.view = GraphicsView(self.splitter) + self.view.setObjectName("view") + self.layoutWidget = QtWidgets.QWidget(self.splitter) + self.layoutWidget.setObjectName("layoutWidget") + self.gridLayout_2 = QtWidgets.QGridLayout(self.layoutWidget) + self.gridLayout_2.setContentsMargins(0, 0, 0, 0) + self.gridLayout_2.setObjectName("gridLayout_2") + self.autoRangeBtn = QtWidgets.QPushButton(self.layoutWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.autoRangeBtn.sizePolicy().hasHeightForWidth()) + self.autoRangeBtn.setSizePolicy(sizePolicy) + self.autoRangeBtn.setObjectName("autoRangeBtn") + self.gridLayout_2.addWidget(self.autoRangeBtn, 2, 0, 1, 2) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setSpacing(0) + self.horizontalLayout.setObjectName("horizontalLayout") + self.redirectCheck = QtWidgets.QCheckBox(self.layoutWidget) + self.redirectCheck.setObjectName("redirectCheck") + self.horizontalLayout.addWidget(self.redirectCheck) + self.redirectCombo = CanvasCombo(self.layoutWidget) + self.redirectCombo.setObjectName("redirectCombo") + self.horizontalLayout.addWidget(self.redirectCombo) + self.gridLayout_2.addLayout(self.horizontalLayout, 5, 0, 1, 2) + self.itemList = TreeWidget(self.layoutWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(100) + sizePolicy.setHeightForWidth(self.itemList.sizePolicy().hasHeightForWidth()) + self.itemList.setSizePolicy(sizePolicy) + self.itemList.setHeaderHidden(True) + self.itemList.setObjectName("itemList") + self.itemList.headerItem().setText(0, "1") + self.gridLayout_2.addWidget(self.itemList, 6, 0, 1, 2) + self.ctrlLayout = QtWidgets.QGridLayout() + self.ctrlLayout.setSpacing(0) + self.ctrlLayout.setObjectName("ctrlLayout") + self.gridLayout_2.addLayout(self.ctrlLayout, 10, 0, 1, 2) + self.resetTransformsBtn = QtWidgets.QPushButton(self.layoutWidget) + self.resetTransformsBtn.setObjectName("resetTransformsBtn") + self.gridLayout_2.addWidget(self.resetTransformsBtn, 7, 0, 1, 1) + self.mirrorSelectionBtn = QtWidgets.QPushButton(self.layoutWidget) + self.mirrorSelectionBtn.setObjectName("mirrorSelectionBtn") + self.gridLayout_2.addWidget(self.mirrorSelectionBtn, 3, 0, 1, 1) + self.reflectSelectionBtn = QtWidgets.QPushButton(self.layoutWidget) + self.reflectSelectionBtn.setObjectName("reflectSelectionBtn") + self.gridLayout_2.addWidget(self.reflectSelectionBtn, 3, 1, 1, 1) + self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.autoRangeBtn.setText(QtWidgets.QApplication.translate("Form", "Auto Range", None, -1)) + self.redirectCheck.setToolTip(QtWidgets.QApplication.translate("Form", "Check to display all local items in a remote canvas.", None, -1)) + self.redirectCheck.setText(QtWidgets.QApplication.translate("Form", "Redirect", None, -1)) + self.resetTransformsBtn.setText(QtWidgets.QApplication.translate("Form", "Reset Transforms", None, -1)) + self.mirrorSelectionBtn.setText(QtWidgets.QApplication.translate("Form", "Mirror Selection", None, -1)) + self.reflectSelectionBtn.setText(QtWidgets.QApplication.translate("Form", "MirrorXY", None, -1)) + +from ..widgets.TreeWidget import TreeWidget +from CanvasManager import CanvasCombo +from ..widgets.GraphicsView import GraphicsView diff --git a/pyqtgraph/canvas/TransformGuiTemplate.ui b/pyqtgraph/canvas/TransformGuiTemplate.ui index d831238819..c63979e0de 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate.ui +++ b/pyqtgraph/canvas/TransformGuiTemplate.ui @@ -17,7 +17,7 @@ - Form + PyQtGraph diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py b/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py index 75c694c074..7cbb3652b8 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py +++ b/pyqtgraph/canvas/TransformGuiTemplate_pyqt.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/canvas/TransformGuiTemplate.ui' +# Form implementation generated from reading ui file 'pyqtgraph/canvas/TransformGuiTemplate.ui' # -# Created: Mon Dec 23 10:10:52 2013 -# by: PyQt4 UI code generator 4.10 +# Created by: PyQt4 UI code generator 4.11.4 # # WARNING! All changes made in this file will be lost! @@ -33,8 +32,8 @@ def setupUi(self, Form): sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) Form.setSizePolicy(sizePolicy) self.verticalLayout = QtGui.QVBoxLayout(Form) - self.verticalLayout.setSpacing(1) self.verticalLayout.setMargin(0) + self.verticalLayout.setSpacing(1) self.verticalLayout.setObjectName(_fromUtf8("verticalLayout")) self.translateLabel = QtGui.QLabel(Form) self.translateLabel.setObjectName(_fromUtf8("translateLabel")) @@ -60,7 +59,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) self.translateLabel.setText(_translate("Form", "Translate:", None)) self.rotateLabel.setText(_translate("Form", "Rotate:", None)) self.scaleLabel.setText(_translate("Form", "Scale:", None)) diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py b/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py index 549f300844..2af0499a65 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py +++ b/pyqtgraph/canvas/TransformGuiTemplate_pyqt5.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/canvas/TransformGuiTemplate.ui' +# Form implementation generated from reading ui file 'pyqtgraph/canvas/TransformGuiTemplate.ui' # -# Created: Wed Mar 26 15:09:28 2014 -# by: PyQt5 UI code generator 5.0.1 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -19,8 +18,8 @@ def setupUi(self, Form): sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) Form.setSizePolicy(sizePolicy) self.verticalLayout = QtWidgets.QVBoxLayout(Form) - self.verticalLayout.setSpacing(1) self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setSpacing(1) self.verticalLayout.setObjectName("verticalLayout") self.translateLabel = QtWidgets.QLabel(Form) self.translateLabel.setObjectName("translateLabel") @@ -47,7 +46,7 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.translateLabel.setText(_translate("Form", "Translate:")) self.rotateLabel.setText(_translate("Form", "Rotate:")) self.scaleLabel.setText(_translate("Form", "Scale:")) diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyside.py b/pyqtgraph/canvas/TransformGuiTemplate_pyside.py index bce7b511df..766203421b 100644 --- a/pyqtgraph/canvas/TransformGuiTemplate_pyside.py +++ b/pyqtgraph/canvas/TransformGuiTemplate_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/canvas/TransformGuiTemplate.ui' +# Form implementation generated from reading ui file 'pyqtgraph/canvas/TransformGuiTemplate.ui' # -# Created: Mon Dec 23 10:10:52 2013 -# by: pyside-uic 0.2.14 running on PySide 1.1.2 +# Created: Wed Nov 9 17:57:16 2016 +# by: pyside-uic 0.2.15 running on PySide 1.2.2 # # WARNING! All changes made in this file will be lost! @@ -46,7 +46,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.translateLabel.setText(QtGui.QApplication.translate("Form", "Translate:", None, QtGui.QApplication.UnicodeUTF8)) self.rotateLabel.setText(QtGui.QApplication.translate("Form", "Rotate:", None, QtGui.QApplication.UnicodeUTF8)) self.scaleLabel.setText(QtGui.QApplication.translate("Form", "Scale:", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/pyqtgraph/canvas/TransformGuiTemplate_pyside2.py b/pyqtgraph/canvas/TransformGuiTemplate_pyside2.py new file mode 100644 index 0000000000..e05ceb145b --- /dev/null +++ b/pyqtgraph/canvas/TransformGuiTemplate_pyside2.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'TransformGuiTemplate.ui' +# +# Created: Sun Sep 18 19:18:41 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(224, 117) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(Form.sizePolicy().hasHeightForWidth()) + Form.setSizePolicy(sizePolicy) + self.verticalLayout = QtWidgets.QVBoxLayout(Form) + self.verticalLayout.setSpacing(1) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setObjectName("verticalLayout") + self.translateLabel = QtWidgets.QLabel(Form) + self.translateLabel.setObjectName("translateLabel") + self.verticalLayout.addWidget(self.translateLabel) + self.rotateLabel = QtWidgets.QLabel(Form) + self.rotateLabel.setObjectName("rotateLabel") + self.verticalLayout.addWidget(self.rotateLabel) + self.scaleLabel = QtWidgets.QLabel(Form) + self.scaleLabel.setObjectName("scaleLabel") + self.verticalLayout.addWidget(self.scaleLabel) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.mirrorImageBtn = QtWidgets.QPushButton(Form) + self.mirrorImageBtn.setToolTip("") + self.mirrorImageBtn.setObjectName("mirrorImageBtn") + self.horizontalLayout.addWidget(self.mirrorImageBtn) + self.reflectImageBtn = QtWidgets.QPushButton(Form) + self.reflectImageBtn.setObjectName("reflectImageBtn") + self.horizontalLayout.addWidget(self.reflectImageBtn) + self.verticalLayout.addLayout(self.horizontalLayout) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.translateLabel.setText(QtWidgets.QApplication.translate("Form", "Translate:", None, -1)) + self.rotateLabel.setText(QtWidgets.QApplication.translate("Form", "Rotate:", None, -1)) + self.scaleLabel.setText(QtWidgets.QApplication.translate("Form", "Scale:", None, -1)) + self.mirrorImageBtn.setText(QtWidgets.QApplication.translate("Form", "Mirror", None, -1)) + self.reflectImageBtn.setText(QtWidgets.QApplication.translate("Form", "Reflect", None, -1)) + diff --git a/pyqtgraph/colormap.py b/pyqtgraph/colormap.py index f943e2fe06..eb423634dc 100644 --- a/pyqtgraph/colormap.py +++ b/pyqtgraph/colormap.py @@ -1,6 +1,7 @@ import numpy as np from .Qt import QtGui, QtCore from .python2_3 import basestring +from .functions import mkColor class ColorMap(object): @@ -56,9 +57,9 @@ def __init__(self, pos, color, mode=None): =============== ============================================================== **Arguments:** pos Array of positions where each color is defined - color Array of RGBA colors. - Integer data types are interpreted as 0-255; float data types - are interpreted as 0.0-1.0 + color Array of colors. + Values are interpreted via + :func:`mkColor() `. mode Array of color modes (ColorMap.RGB, HSV_POS, or HSV_NEG) indicating the color space that should be used when interpolating between stops. Note that the last mode value is @@ -68,7 +69,11 @@ def __init__(self, pos, color, mode=None): self.pos = np.array(pos) order = np.argsort(self.pos) self.pos = self.pos[order] - self.color = np.array(color)[order] + self.color = np.apply_along_axis( + func1d = lambda x: mkColor(x).getRgb(), + axis = -1, + arr = color, + )[order] if mode is None: mode = np.ones(len(pos)) self.mode = mode @@ -141,7 +146,7 @@ def getGradient(self, p1=None, p2=None): pos, color = self.getStops(mode=self.BYTE) color = [QtGui.QColor(*x) for x in color] - g.setStops(zip(pos, color)) + g.setStops(list(zip(pos, color))) #if self.colorMode == 'rgb': #ticks = self.listTicks() @@ -225,7 +230,7 @@ def getLookupTable(self, start=0.0, stop=1.0, nPts=512, alpha=None, mode='byte') x = np.linspace(start, stop, nPts) table = self.map(x, mode) - if not alpha: + if not alpha and mode != self.QCOLOR: return table[:,:3] else: return table diff --git a/pyqtgraph/configfile.py b/pyqtgraph/configfile.py index b8a8c44c18..6ae8a0c53e 100644 --- a/pyqtgraph/configfile.py +++ b/pyqtgraph/configfile.py @@ -9,7 +9,7 @@ as it can be converted to/from a string using repr and eval. """ -import re, os, sys +import re, os, sys, datetime import numpy from .pgcollections import OrderedDict from . import units @@ -33,17 +33,16 @@ def __str__(self): msg = "Error parsing string at line %d:\n" % self.lineNum else: msg = "Error parsing config file '%s' at line %d:\n" % (self.fileName, self.lineNum) - msg += "%s\n%s" % (self.line, self.message) + msg += "%s\n%s" % (self.line, Exception.__str__(self)) return msg - #raise Exception() def writeConfigFile(data, fname): s = genString(data) - fd = open(fname, 'w') - fd.write(s) - fd.close() - + with open(fname, 'w') as fd: + fd.write(s) + + def readConfigFile(fname): #cwd = os.getcwd() global GLOBAL_PATH @@ -56,9 +55,8 @@ def readConfigFile(fname): try: #os.chdir(newDir) ## bad. - fd = open(fname) - s = asUnicode(fd.read()) - fd.close() + with open(fname) as fd: + s = asUnicode(fd.read()) s = s.replace("\r\n", "\n") s = s.replace("\r", "\n") data = parseString(s)[1] @@ -74,9 +72,8 @@ def readConfigFile(fname): def appendConfigFile(data, fname): s = genString(data) - fd = open(fname, 'a') - fd.write(s) - fd.close() + with open(fname, 'a') as fd: + fd.write(s) def genString(data, indent=''): @@ -93,13 +90,14 @@ def genString(data, indent=''): s += indent + sk + ':\n' s += genString(data[k], indent + ' ') else: - s += indent + sk + ': ' + repr(data[k]) + '\n' + s += indent + sk + ': ' + repr(data[k]).replace("\n", "\\\n") + '\n' return s def parseString(lines, start=0): data = OrderedDict() if isinstance(lines, basestring): + lines = lines.replace("\\\n", "") lines = lines.split('\n') lines = [l for l in lines if re.search(r'\S', l) and not re.match(r'\s*#', l)] ## remove empty lines @@ -143,6 +141,7 @@ def parseString(lines, start=0): local['Point'] = Point local['QtCore'] = QtCore local['ColorMap'] = ColorMap + local['datetime'] = datetime # Needed for reconstructing numpy arrays local['array'] = numpy.array for dtype in ['int8', 'uint8', @@ -193,8 +192,6 @@ def measureIndent(s): if __name__ == '__main__': import tempfile - fn = tempfile.mktemp() - tf = open(fn, 'w') cf = """ key: 'value' key2: ##comment @@ -204,8 +201,9 @@ def measureIndent(s): key22: [1,2,3] key23: 234 #comment """ - tf.write(cf) - tf.close() + fn = tempfile.mktemp() + with open(fn, 'w') as tf: + tf.write(cf) print("=== Test:===") num = 1 for line in cf.split('\n'): diff --git a/pyqtgraph/console/CmdInput.py b/pyqtgraph/console/CmdInput.py index 24a01e8949..21d2538229 100644 --- a/pyqtgraph/console/CmdInput.py +++ b/pyqtgraph/console/CmdInput.py @@ -9,19 +9,18 @@ def __init__(self, parent): QtGui.QLineEdit.__init__(self, parent) self.history = [""] self.ptr = 0 - #self.lastCmd = None - #self.setMultiline(False) def keyPressEvent(self, ev): - #print "press:", ev.key(), QtCore.Qt.Key_Up, QtCore.Qt.Key_Down, QtCore.Qt.Key_Enter - if ev.key() == QtCore.Qt.Key_Up and self.ptr < len(self.history) - 1: - self.setHistory(self.ptr+1) - ev.accept() - return - elif ev.key() == QtCore.Qt.Key_Down and self.ptr > 0: - self.setHistory(self.ptr-1) - ev.accept() - return + if ev.key() == QtCore.Qt.Key_Up: + if self.ptr < len(self.history) - 1: + self.setHistory(self.ptr+1) + ev.accept() + return + elif ev.key() == QtCore.Qt.Key_Down: + if self.ptr > 0: + self.setHistory(self.ptr-1) + ev.accept() + return elif ev.key() == QtCore.Qt.Key_Return: self.execCmd() else: @@ -32,7 +31,6 @@ def execCmd(self): cmd = asUnicode(self.text()) if len(self.history) == 1 or cmd != self.history[1]: self.history.insert(1, cmd) - #self.lastCmd = cmd self.history[0] = "" self.setHistory(0) self.sigExecuteCmd.emit(cmd) @@ -40,23 +38,3 @@ def execCmd(self): def setHistory(self, num): self.ptr = num self.setText(self.history[self.ptr]) - - #def setMultiline(self, m): - #height = QtGui.QFontMetrics(self.font()).lineSpacing() - #if m: - #self.setFixedHeight(height*5) - #else: - #self.setFixedHeight(height+15) - #self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - #self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - - - #def sizeHint(self): - #hint = QtGui.QPlainTextEdit.sizeHint(self) - #height = QtGui.QFontMetrics(self.font()).lineSpacing() - #hint.setHeight(height) - #return hint - - - - \ No newline at end of file diff --git a/pyqtgraph/console/Console.py b/pyqtgraph/console/Console.py index 3ea1580f6b..aac32d6304 100644 --- a/pyqtgraph/console/Console.py +++ b/pyqtgraph/console/Console.py @@ -1,13 +1,17 @@ +# -*- coding: utf-8 -*- import sys, re, os, time, traceback, subprocess import pickle -from ..Qt import QtCore, QtGui, USE_PYSIDE, USE_PYQT5 +from ..Qt import QtCore, QtGui, QT_LIB from ..python2_3 import basestring from .. import exceptionHandling as exceptionHandling from .. import getConfigOption -if USE_PYSIDE: +from ..functions import SignalBlock +if QT_LIB == 'PySide': from . import template_pyside as template -elif USE_PYQT5: +elif QT_LIB == 'PySide2': + from . import template_pyside2 as template +elif QT_LIB == 'PyQt5': from . import template_pyqt5 as template else: from . import template_pyqt as template @@ -31,6 +35,7 @@ class ConsoleWidget(QtGui.QWidget): - ability to add extra features like exception stack introspection - ability to have multiple interactive prompts, including for spawned sub-processes """ + _threadException = QtCore.Signal(object) def __init__(self, parent=None, namespace=None, historyFile=None, text=None, editor=None): """ @@ -48,10 +53,12 @@ def __init__(self, parent=None, namespace=None, historyFile=None, text=None, edi QtGui.QWidget.__init__(self, parent) if namespace is None: namespace = {} + namespace['__console__'] = self self.localNamespace = namespace self.editor = editor self.multiline = None self.inCmd = False + self.frames = [] # stack frames to access when an item in the stack list is selected self.ui = template.Ui_Form() self.ui.setupUi(self) @@ -85,19 +92,23 @@ def __init__(self, parent=None, namespace=None, historyFile=None, text=None, edi self.ui.onlyUncaughtCheck.toggled.connect(self.updateSysTrace) self.currentTraceback = None + + # send exceptions raised in non-gui threads back to the main thread by signal. + self._threadException.connect(self._threadExceptionHandler) def loadHistory(self): """Return the list of previously-invoked command strings (or None).""" if self.historyFile is not None: - return pickle.load(open(self.historyFile, 'rb')) + with open(self.historyFile, 'rb') as pf: + return pickle.load(pf) def saveHistory(self, history): """Store the list of previously-invoked command strings.""" if self.historyFile is not None: - pickle.dump(open(self.historyFile, 'wb'), history) + with open(self.historyFile, 'wb') as pf: + pickle.dump(pf, history) def runCmd(self, cmd): - #cmd = str(self.input.lastCmd) self.stdout = sys.stdout self.stderr = sys.stderr encCmd = re.sub(r'>', '>', re.sub(r'<', '<', cmd)) @@ -110,48 +121,44 @@ def runCmd(self, cmd): sys.stdout = self sys.stderr = self if self.multiline is not None: - self.write("
%s\n"%encCmd, html=True) + self.write("
%s\n"%encCmd, html=True, scrollToBottom=True) self.execMulti(cmd) else: - self.write("
%s\n"%encCmd, html=True) + self.write("
%s\n"%encCmd, html=True, scrollToBottom=True) self.inCmd = True self.execSingle(cmd) if not self.inCmd: - self.write("
\n", html=True) + self.write("
\n", html=True, scrollToBottom=True) finally: sys.stdout = self.stdout sys.stderr = self.stderr - sb = self.output.verticalScrollBar() - sb.setValue(sb.maximum()) sb = self.ui.historyList.verticalScrollBar() sb.setValue(sb.maximum()) def globals(self): frame = self.currentFrame() if frame is not None and self.ui.runSelectedFrameCheck.isChecked(): - return self.currentFrame().tb_frame.f_globals + return self.currentFrame().f_globals else: - return globals() + return self.localNamespace def locals(self): frame = self.currentFrame() if frame is not None and self.ui.runSelectedFrameCheck.isChecked(): - return self.currentFrame().tb_frame.f_locals + return self.currentFrame().f_locals else: return self.localNamespace def currentFrame(self): ## Return the currently selected exception stack frame (or None if there is no exception) - if self.currentTraceback is None: - return None index = self.ui.exceptionStackList.currentRow() - tb = self.currentTraceback - for i in range(index): - tb = tb.tb_next - return tb + if index >= 0 and index < len(self.frames): + return self.frames[index] + else: + return None def execSingle(self, cmd): try: @@ -170,7 +177,6 @@ def execSingle(self, cmd): except: self.displayException() - def execMulti(self, nextLine): #self.stdout.write(nextLine+"\n") if nextLine.strip() != '': @@ -200,18 +206,37 @@ def execMulti(self, nextLine): self.displayException() self.multiline = None - def write(self, strn, html=False): + def write(self, strn, html=False, scrollToBottom='auto'): + """Write a string into the console. + + If scrollToBottom is 'auto', then the console is automatically scrolled + to fit the new text only if it was already at the bottom. + """ + isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() + if not isGuiThread: + self.stdout.write(strn) + return + + sb = self.output.verticalScrollBar() + scroll = sb.value() + if scrollToBottom == 'auto': + atBottom = scroll == sb.maximum() + scrollToBottom = atBottom + self.output.moveCursor(QtGui.QTextCursor.End) if html: self.output.textCursor().insertHtml(strn) else: if self.inCmd: self.inCmd = False - self.output.textCursor().insertHtml("
") - #self.stdout.write("

") + self.output.textCursor().insertHtml("

") self.output.insertPlainText(strn) - #self.stdout.write(strn) - + + if scrollToBottom: + sb.setValue(sb.maximum()) + else: + sb.setValue(scroll) + def displayException(self): """ Display the current exception and stack. @@ -243,9 +268,12 @@ def catchAllExceptions(self, catch=True): If True, the console will catch all unhandled exceptions and display the stack trace. Each exception caught clears the last. """ - self.ui.catchAllExceptionsBtn.setChecked(catch) + with SignalBlock(self.ui.catchAllExceptionsBtn.toggled, self.catchAllExceptions): + self.ui.catchAllExceptionsBtn.setChecked(catch) + if catch: - self.ui.catchNextExceptionBtn.setChecked(False) + with SignalBlock(self.ui.catchNextExceptionBtn.toggled, self.catchNextException): + self.ui.catchNextExceptionBtn.setChecked(False) self.enableExceptionHandling() self.ui.exceptionBtn.setChecked(True) else: @@ -256,9 +284,11 @@ def catchNextException(self, catch=True): If True, the console will catch the next unhandled exception and display the stack trace. """ - self.ui.catchNextExceptionBtn.setChecked(catch) + with SignalBlock(self.ui.catchNextExceptionBtn.toggled, self.catchNextException): + self.ui.catchNextExceptionBtn.setChecked(catch) if catch: - self.ui.catchAllExceptionsBtn.setChecked(False) + with SignalBlock(self.ui.catchAllExceptionsBtn.toggled, self.catchAllExceptions): + self.ui.catchAllExceptionsBtn.setChecked(False) self.enableExceptionHandling() self.ui.exceptionBtn.setChecked(True) else: @@ -274,6 +304,7 @@ def disableExceptionHandling(self): def clearExceptionClicked(self): self.currentTraceback = None + self.frames = [] self.ui.exceptionInfoLabel.setText("[No current exception]") self.ui.exceptionStackList.clear() self.ui.clearExceptionBtn.setEnabled(False) @@ -292,14 +323,6 @@ def stackItemDblClicked(self, item): fileName = tb.tb_frame.f_code.co_filename subprocess.Popen(self.editor.format(fileName=fileName, lineNum=lineNum), shell=True) - - #def allExceptionsHandler(self, *args): - #self.exceptionHandler(*args) - - #def nextExceptionHandler(self, *args): - #self.ui.catchNextExceptionBtn.setChecked(False) - #self.exceptionHandler(*args) - def updateSysTrace(self): ## Install or uninstall sys.settrace handler @@ -318,24 +341,95 @@ def updateSysTrace(self): else: sys.settrace(self.systrace) - def exceptionHandler(self, excType, exc, tb): + def exceptionHandler(self, excType, exc, tb, systrace=False, frame=None): + if frame is None: + frame = sys._getframe() + + # exceptions raised in non-gui threads must be handled separately + isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() + if not isGuiThread: + # sending a frame from one thread to another.. probably not safe, but better than just + # dropping the exception? + self._threadException.emit((excType, exc, tb, systrace, frame.f_back)) + return + if self.ui.catchNextExceptionBtn.isChecked(): self.ui.catchNextExceptionBtn.setChecked(False) elif not self.ui.catchAllExceptionsBtn.isChecked(): return - self.ui.clearExceptionBtn.setEnabled(True) self.currentTraceback = tb excMessage = ''.join(traceback.format_exception_only(excType, exc)) self.ui.exceptionInfoLabel.setText(excMessage) + + if systrace: + # exceptions caught using systrace don't need the usual + # call stack + traceback handling + self.setStack(frame.f_back.f_back) + else: + self.setStack(frame=frame.f_back, tb=tb) + + def _threadExceptionHandler(self, args): + self.exceptionHandler(*args) + + def setStack(self, frame=None, tb=None): + """Display a call stack and exception traceback. + + This allows the user to probe the contents of any frame in the given stack. + + *frame* may either be a Frame instance or None, in which case the current + frame is retrieved from ``sys._getframe()``. + + If *tb* is provided then the frames in the traceback will be appended to + the end of the stack list. If *tb* is None, then sys.exc_info() will + be checked instead. + """ + self.ui.clearExceptionBtn.setEnabled(True) + + if frame is None: + frame = sys._getframe().f_back + + if tb is None: + tb = sys.exc_info()[2] + self.ui.exceptionStackList.clear() + self.frames = [] + + # Build stack up to this point + for index, line in enumerate(traceback.extract_stack(frame)): + # extract_stack return value changed in python 3.5 + if 'FrameSummary' in str(type(line)): + line = (line.filename, line.lineno, line.name, line._line) + + self.ui.exceptionStackList.addItem('File "%s", line %s, in %s()\n %s' % line) + while frame is not None: + self.frames.insert(0, frame) + frame = frame.f_back + + if tb is None: + return + + self.ui.exceptionStackList.addItem('-- exception caught here: --') + item = self.ui.exceptionStackList.item(self.ui.exceptionStackList.count()-1) + item.setBackground(QtGui.QBrush(QtGui.QColor(200, 200, 200))) + item.setForeground(QtGui.QBrush(QtGui.QColor(50, 50, 50))) + self.frames.append(None) + + # And finish the rest of the stack up to the exception for index, line in enumerate(traceback.extract_tb(tb)): + # extract_stack return value changed in python 3.5 + if 'FrameSummary' in str(type(line)): + line = (line.filename, line.lineno, line.name, line._line) + self.ui.exceptionStackList.addItem('File "%s", line %s, in %s()\n %s' % line) - + while tb is not None: + self.frames.append(tb.tb_frame) + tb = tb.tb_next + def systrace(self, frame, event, arg): if event == 'exception' and self.checkException(*arg): - self.exceptionHandler(*arg) + self.exceptionHandler(*arg, systrace=True) return self.systrace def checkException(self, excType, exc, tb): diff --git a/pyqtgraph/console/template.ui b/pyqtgraph/console/template.ui index 1a672c5e41..1237b5f3b1 100644 --- a/pyqtgraph/console/template.ui +++ b/pyqtgraph/console/template.ui @@ -6,7 +6,7 @@ 0 0 - 694 + 739 497 @@ -86,7 +86,10 @@ 0 - + + 2 + + 0 @@ -95,7 +98,7 @@ false - Clear Exception + Clear Stack @@ -149,7 +152,10 @@ - Exception Info + Stack Trace + + + true diff --git a/pyqtgraph/console/template_pyqt.py b/pyqtgraph/console/template_pyqt.py index 354fb1d65b..9b39d14a4d 100644 --- a/pyqtgraph/console/template_pyqt.py +++ b/pyqtgraph/console/template_pyqt.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'template.ui' +# Form implementation generated from reading ui file 'pyqtgraph/console/template.ui' # -# Created: Fri May 02 18:55:28 2014 -# by: PyQt4 UI code generator 4.10.4 +# Created by: PyQt4 UI code generator 4.11.4 # # WARNING! All changes made in this file will be lost! @@ -26,7 +25,7 @@ def _translate(context, text, disambig): class Ui_Form(object): def setupUi(self, Form): Form.setObjectName(_fromUtf8("Form")) - Form.resize(694, 497) + Form.resize(739, 497) self.gridLayout = QtGui.QGridLayout(Form) self.gridLayout.setMargin(0) self.gridLayout.setSpacing(0) @@ -37,7 +36,6 @@ def setupUi(self, Form): self.layoutWidget = QtGui.QWidget(self.splitter) self.layoutWidget.setObjectName(_fromUtf8("layoutWidget")) self.verticalLayout = QtGui.QVBoxLayout(self.layoutWidget) - self.verticalLayout.setMargin(0) self.verticalLayout.setObjectName(_fromUtf8("verticalLayout")) self.output = QtGui.QPlainTextEdit(self.layoutWidget) font = QtGui.QFont() @@ -68,8 +66,9 @@ def setupUi(self, Form): self.exceptionGroup = QtGui.QGroupBox(self.splitter) self.exceptionGroup.setObjectName(_fromUtf8("exceptionGroup")) self.gridLayout_2 = QtGui.QGridLayout(self.exceptionGroup) - self.gridLayout_2.setSpacing(0) self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) + self.gridLayout_2.setHorizontalSpacing(2) + self.gridLayout_2.setVerticalSpacing(0) self.gridLayout_2.setObjectName(_fromUtf8("gridLayout_2")) self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) self.clearExceptionBtn.setEnabled(False) @@ -96,6 +95,7 @@ def setupUi(self, Form): self.runSelectedFrameCheck.setObjectName(_fromUtf8("runSelectedFrameCheck")) self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) self.exceptionInfoLabel = QtGui.QLabel(self.exceptionGroup) + self.exceptionInfoLabel.setWordWrap(True) self.exceptionInfoLabel.setObjectName(_fromUtf8("exceptionInfoLabel")) self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) @@ -116,12 +116,12 @@ def retranslateUi(self, Form): self.historyBtn.setText(_translate("Form", "History..", None)) self.exceptionBtn.setText(_translate("Form", "Exceptions..", None)) self.exceptionGroup.setTitle(_translate("Form", "Exception Handling", None)) - self.clearExceptionBtn.setText(_translate("Form", "Clear Exception", None)) + self.clearExceptionBtn.setText(_translate("Form", "Clear Stack", None)) self.catchAllExceptionsBtn.setText(_translate("Form", "Show All Exceptions", None)) self.catchNextExceptionBtn.setText(_translate("Form", "Show Next Exception", None)) self.onlyUncaughtCheck.setText(_translate("Form", "Only Uncaught Exceptions", None)) self.runSelectedFrameCheck.setText(_translate("Form", "Run commands in selected stack frame", None)) - self.exceptionInfoLabel.setText(_translate("Form", "Exception Info", None)) + self.exceptionInfoLabel.setText(_translate("Form", "Stack Trace", None)) self.label.setText(_translate("Form", "Filter (regex):", None)) from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyqt5.py b/pyqtgraph/console/template_pyqt5.py index 1fbc5bed2c..c8c2cbacdb 100644 --- a/pyqtgraph/console/template_pyqt5.py +++ b/pyqtgraph/console/template_pyqt5.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/console/template.ui' +# Form implementation generated from reading ui file 'pyqtgraph/console/template.ui' # -# Created: Wed Mar 26 15:09:29 2014 -# by: PyQt5 UI code generator 5.0.1 +# Created by: PyQt5 UI code generator 5.5.1 # # WARNING! All changes made in this file will be lost! @@ -12,7 +11,7 @@ class Ui_Form(object): def setupUi(self, Form): Form.setObjectName("Form") - Form.resize(710, 497) + Form.resize(739, 497) self.gridLayout = QtWidgets.QGridLayout(Form) self.gridLayout.setContentsMargins(0, 0, 0, 0) self.gridLayout.setSpacing(0) @@ -23,7 +22,6 @@ def setupUi(self, Form): self.layoutWidget = QtWidgets.QWidget(self.splitter) self.layoutWidget.setObjectName("layoutWidget") self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget) - self.verticalLayout.setContentsMargins(0, 0, 0, 0) self.verticalLayout.setObjectName("verticalLayout") self.output = QtWidgets.QPlainTextEdit(self.layoutWidget) font = QtGui.QFont() @@ -54,9 +52,14 @@ def setupUi(self, Form): self.exceptionGroup = QtWidgets.QGroupBox(self.splitter) self.exceptionGroup.setObjectName("exceptionGroup") self.gridLayout_2 = QtWidgets.QGridLayout(self.exceptionGroup) - self.gridLayout_2.setSpacing(0) self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) + self.gridLayout_2.setHorizontalSpacing(2) + self.gridLayout_2.setVerticalSpacing(0) self.gridLayout_2.setObjectName("gridLayout_2") + self.clearExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) + self.clearExceptionBtn.setEnabled(False) + self.clearExceptionBtn.setObjectName("clearExceptionBtn") + self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) self.catchAllExceptionsBtn = QtWidgets.QPushButton(self.exceptionGroup) self.catchAllExceptionsBtn.setCheckable(True) self.catchAllExceptionsBtn.setObjectName("catchAllExceptionsBtn") @@ -68,24 +71,27 @@ def setupUi(self, Form): self.onlyUncaughtCheck = QtWidgets.QCheckBox(self.exceptionGroup) self.onlyUncaughtCheck.setChecked(True) self.onlyUncaughtCheck.setObjectName("onlyUncaughtCheck") - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 2, 1, 1) + self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) self.exceptionStackList = QtWidgets.QListWidget(self.exceptionGroup) self.exceptionStackList.setAlternatingRowColors(True) self.exceptionStackList.setObjectName("exceptionStackList") - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 5) + self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) self.runSelectedFrameCheck = QtWidgets.QCheckBox(self.exceptionGroup) self.runSelectedFrameCheck.setChecked(True) self.runSelectedFrameCheck.setObjectName("runSelectedFrameCheck") - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 5) + self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) self.exceptionInfoLabel = QtWidgets.QLabel(self.exceptionGroup) + self.exceptionInfoLabel.setWordWrap(True) self.exceptionInfoLabel.setObjectName("exceptionInfoLabel") - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 5) - self.clearExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName("clearExceptionBtn") - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 4, 1, 1) + self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 3, 1, 1) + self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) + self.label = QtWidgets.QLabel(self.exceptionGroup) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) + self.filterText = QtWidgets.QLineEdit(self.exceptionGroup) + self.filterText.setObjectName("filterText") + self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) @@ -97,11 +103,12 @@ def retranslateUi(self, Form): self.historyBtn.setText(_translate("Form", "History..")) self.exceptionBtn.setText(_translate("Form", "Exceptions..")) self.exceptionGroup.setTitle(_translate("Form", "Exception Handling")) + self.clearExceptionBtn.setText(_translate("Form", "Clear Stack")) self.catchAllExceptionsBtn.setText(_translate("Form", "Show All Exceptions")) self.catchNextExceptionBtn.setText(_translate("Form", "Show Next Exception")) self.onlyUncaughtCheck.setText(_translate("Form", "Only Uncaught Exceptions")) self.runSelectedFrameCheck.setText(_translate("Form", "Run commands in selected stack frame")) - self.exceptionInfoLabel.setText(_translate("Form", "Exception Info")) - self.clearExceptionBtn.setText(_translate("Form", "Clear Exception")) + self.exceptionInfoLabel.setText(_translate("Form", "Stack Trace")) + self.label.setText(_translate("Form", "Filter (regex):")) from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyside.py b/pyqtgraph/console/template_pyside.py index 2db8ed95ac..1579cb1f83 100644 --- a/pyqtgraph/console/template_pyside.py +++ b/pyqtgraph/console/template_pyside.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file './pyqtgraph/console/template.ui' +# Form implementation generated from reading ui file 'pyqtgraph/console/template.ui' # -# Created: Mon Dec 23 10:10:53 2013 -# by: pyside-uic 0.2.14 running on PySide 1.1.2 +# Created: Tue Sep 19 09:45:18 2017 +# by: pyside-uic 0.2.15 running on PySide 1.2.2 # # WARNING! All changes made in this file will be lost! @@ -12,7 +12,7 @@ class Ui_Form(object): def setupUi(self, Form): Form.setObjectName("Form") - Form.resize(710, 497) + Form.resize(739, 497) self.gridLayout = QtGui.QGridLayout(Form) self.gridLayout.setContentsMargins(0, 0, 0, 0) self.gridLayout.setSpacing(0) @@ -54,9 +54,14 @@ def setupUi(self, Form): self.exceptionGroup = QtGui.QGroupBox(self.splitter) self.exceptionGroup.setObjectName("exceptionGroup") self.gridLayout_2 = QtGui.QGridLayout(self.exceptionGroup) - self.gridLayout_2.setSpacing(0) self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) + self.gridLayout_2.setHorizontalSpacing(2) + self.gridLayout_2.setVerticalSpacing(0) self.gridLayout_2.setObjectName("gridLayout_2") + self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) + self.clearExceptionBtn.setEnabled(False) + self.clearExceptionBtn.setObjectName("clearExceptionBtn") + self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) self.catchAllExceptionsBtn = QtGui.QPushButton(self.exceptionGroup) self.catchAllExceptionsBtn.setCheckable(True) self.catchAllExceptionsBtn.setObjectName("catchAllExceptionsBtn") @@ -68,24 +73,27 @@ def setupUi(self, Form): self.onlyUncaughtCheck = QtGui.QCheckBox(self.exceptionGroup) self.onlyUncaughtCheck.setChecked(True) self.onlyUncaughtCheck.setObjectName("onlyUncaughtCheck") - self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 2, 1, 1) + self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) self.exceptionStackList = QtGui.QListWidget(self.exceptionGroup) self.exceptionStackList.setAlternatingRowColors(True) self.exceptionStackList.setObjectName("exceptionStackList") - self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 5) + self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) self.runSelectedFrameCheck = QtGui.QCheckBox(self.exceptionGroup) self.runSelectedFrameCheck.setChecked(True) self.runSelectedFrameCheck.setObjectName("runSelectedFrameCheck") - self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 5) + self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) self.exceptionInfoLabel = QtGui.QLabel(self.exceptionGroup) + self.exceptionInfoLabel.setWordWrap(True) self.exceptionInfoLabel.setObjectName("exceptionInfoLabel") - self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 5) - self.clearExceptionBtn = QtGui.QPushButton(self.exceptionGroup) - self.clearExceptionBtn.setEnabled(False) - self.clearExceptionBtn.setObjectName("clearExceptionBtn") - self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 4, 1, 1) + self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) spacerItem = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) - self.gridLayout_2.addItem(spacerItem, 0, 3, 1, 1) + self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) + self.label = QtGui.QLabel(self.exceptionGroup) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) + self.filterText = QtGui.QLineEdit(self.exceptionGroup) + self.filterText.setObjectName("filterText") + self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) self.retranslateUi(Form) @@ -96,11 +104,12 @@ def retranslateUi(self, Form): self.historyBtn.setText(QtGui.QApplication.translate("Form", "History..", None, QtGui.QApplication.UnicodeUTF8)) self.exceptionBtn.setText(QtGui.QApplication.translate("Form", "Exceptions..", None, QtGui.QApplication.UnicodeUTF8)) self.exceptionGroup.setTitle(QtGui.QApplication.translate("Form", "Exception Handling", None, QtGui.QApplication.UnicodeUTF8)) + self.clearExceptionBtn.setText(QtGui.QApplication.translate("Form", "Clear Stack", None, QtGui.QApplication.UnicodeUTF8)) self.catchAllExceptionsBtn.setText(QtGui.QApplication.translate("Form", "Show All Exceptions", None, QtGui.QApplication.UnicodeUTF8)) self.catchNextExceptionBtn.setText(QtGui.QApplication.translate("Form", "Show Next Exception", None, QtGui.QApplication.UnicodeUTF8)) self.onlyUncaughtCheck.setText(QtGui.QApplication.translate("Form", "Only Uncaught Exceptions", None, QtGui.QApplication.UnicodeUTF8)) self.runSelectedFrameCheck.setText(QtGui.QApplication.translate("Form", "Run commands in selected stack frame", None, QtGui.QApplication.UnicodeUTF8)) - self.exceptionInfoLabel.setText(QtGui.QApplication.translate("Form", "Exception Info", None, QtGui.QApplication.UnicodeUTF8)) - self.clearExceptionBtn.setText(QtGui.QApplication.translate("Form", "Clear Exception", None, QtGui.QApplication.UnicodeUTF8)) + self.exceptionInfoLabel.setText(QtGui.QApplication.translate("Form", "Stack Trace", None, QtGui.QApplication.UnicodeUTF8)) + self.label.setText(QtGui.QApplication.translate("Form", "Filter (regex):", None, QtGui.QApplication.UnicodeUTF8)) from .CmdInput import CmdInput diff --git a/pyqtgraph/console/template_pyside2.py b/pyqtgraph/console/template_pyside2.py new file mode 100644 index 0000000000..c8662c742f --- /dev/null +++ b/pyqtgraph/console/template_pyside2.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'template.ui' +# +# Created: Sun Sep 18 19:19:10 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(694, 497) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setSpacing(0) + self.gridLayout.setObjectName("gridLayout") + self.splitter = QtWidgets.QSplitter(Form) + self.splitter.setOrientation(QtCore.Qt.Vertical) + self.splitter.setObjectName("splitter") + self.layoutWidget = QtWidgets.QWidget(self.splitter) + self.layoutWidget.setObjectName("layoutWidget") + self.verticalLayout = QtWidgets.QVBoxLayout(self.layoutWidget) + self.verticalLayout.setContentsMargins(0, 0, 0, 0) + self.verticalLayout.setObjectName("verticalLayout") + self.output = QtWidgets.QPlainTextEdit(self.layoutWidget) + font = QtGui.QFont() + font.setFamily("Monospace") + self.output.setFont(font) + self.output.setReadOnly(True) + self.output.setObjectName("output") + self.verticalLayout.addWidget(self.output) + self.horizontalLayout = QtWidgets.QHBoxLayout() + self.horizontalLayout.setObjectName("horizontalLayout") + self.input = CmdInput(self.layoutWidget) + self.input.setObjectName("input") + self.horizontalLayout.addWidget(self.input) + self.historyBtn = QtWidgets.QPushButton(self.layoutWidget) + self.historyBtn.setCheckable(True) + self.historyBtn.setObjectName("historyBtn") + self.horizontalLayout.addWidget(self.historyBtn) + self.exceptionBtn = QtWidgets.QPushButton(self.layoutWidget) + self.exceptionBtn.setCheckable(True) + self.exceptionBtn.setObjectName("exceptionBtn") + self.horizontalLayout.addWidget(self.exceptionBtn) + self.verticalLayout.addLayout(self.horizontalLayout) + self.historyList = QtWidgets.QListWidget(self.splitter) + font = QtGui.QFont() + font.setFamily("Monospace") + self.historyList.setFont(font) + self.historyList.setObjectName("historyList") + self.exceptionGroup = QtWidgets.QGroupBox(self.splitter) + self.exceptionGroup.setObjectName("exceptionGroup") + self.gridLayout_2 = QtWidgets.QGridLayout(self.exceptionGroup) + self.gridLayout_2.setSpacing(0) + self.gridLayout_2.setContentsMargins(-1, 0, -1, 0) + self.gridLayout_2.setObjectName("gridLayout_2") + self.clearExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) + self.clearExceptionBtn.setEnabled(False) + self.clearExceptionBtn.setObjectName("clearExceptionBtn") + self.gridLayout_2.addWidget(self.clearExceptionBtn, 0, 6, 1, 1) + self.catchAllExceptionsBtn = QtWidgets.QPushButton(self.exceptionGroup) + self.catchAllExceptionsBtn.setCheckable(True) + self.catchAllExceptionsBtn.setObjectName("catchAllExceptionsBtn") + self.gridLayout_2.addWidget(self.catchAllExceptionsBtn, 0, 1, 1, 1) + self.catchNextExceptionBtn = QtWidgets.QPushButton(self.exceptionGroup) + self.catchNextExceptionBtn.setCheckable(True) + self.catchNextExceptionBtn.setObjectName("catchNextExceptionBtn") + self.gridLayout_2.addWidget(self.catchNextExceptionBtn, 0, 0, 1, 1) + self.onlyUncaughtCheck = QtWidgets.QCheckBox(self.exceptionGroup) + self.onlyUncaughtCheck.setChecked(True) + self.onlyUncaughtCheck.setObjectName("onlyUncaughtCheck") + self.gridLayout_2.addWidget(self.onlyUncaughtCheck, 0, 4, 1, 1) + self.exceptionStackList = QtWidgets.QListWidget(self.exceptionGroup) + self.exceptionStackList.setAlternatingRowColors(True) + self.exceptionStackList.setObjectName("exceptionStackList") + self.gridLayout_2.addWidget(self.exceptionStackList, 2, 0, 1, 7) + self.runSelectedFrameCheck = QtWidgets.QCheckBox(self.exceptionGroup) + self.runSelectedFrameCheck.setChecked(True) + self.runSelectedFrameCheck.setObjectName("runSelectedFrameCheck") + self.gridLayout_2.addWidget(self.runSelectedFrameCheck, 3, 0, 1, 7) + self.exceptionInfoLabel = QtWidgets.QLabel(self.exceptionGroup) + self.exceptionInfoLabel.setObjectName("exceptionInfoLabel") + self.gridLayout_2.addWidget(self.exceptionInfoLabel, 1, 0, 1, 7) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.gridLayout_2.addItem(spacerItem, 0, 5, 1, 1) + self.label = QtWidgets.QLabel(self.exceptionGroup) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 0, 2, 1, 1) + self.filterText = QtWidgets.QLineEdit(self.exceptionGroup) + self.filterText.setObjectName("filterText") + self.gridLayout_2.addWidget(self.filterText, 0, 3, 1, 1) + self.gridLayout.addWidget(self.splitter, 0, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Console", None, -1)) + self.historyBtn.setText(QtWidgets.QApplication.translate("Form", "History..", None, -1)) + self.exceptionBtn.setText(QtWidgets.QApplication.translate("Form", "Exceptions..", None, -1)) + self.exceptionGroup.setTitle(QtWidgets.QApplication.translate("Form", "Exception Handling", None, -1)) + self.clearExceptionBtn.setText(QtWidgets.QApplication.translate("Form", "Clear Exception", None, -1)) + self.catchAllExceptionsBtn.setText(QtWidgets.QApplication.translate("Form", "Show All Exceptions", None, -1)) + self.catchNextExceptionBtn.setText(QtWidgets.QApplication.translate("Form", "Show Next Exception", None, -1)) + self.onlyUncaughtCheck.setText(QtWidgets.QApplication.translate("Form", "Only Uncaught Exceptions", None, -1)) + self.runSelectedFrameCheck.setText(QtWidgets.QApplication.translate("Form", "Run commands in selected stack frame", None, -1)) + self.exceptionInfoLabel.setText(QtWidgets.QApplication.translate("Form", "Exception Info", None, -1)) + self.label.setText(QtWidgets.QApplication.translate("Form", "Filter (regex):", None, -1)) + +from .CmdInput import CmdInput diff --git a/pyqtgraph/debug.py b/pyqtgraph/debug.py index d6da1691cf..bc6d6895e4 100644 --- a/pyqtgraph/debug.py +++ b/pyqtgraph/debug.py @@ -510,7 +510,7 @@ def __new__(cls, msg=None, disabled='env', delayed=True): try: caller_object_type = type(caller_frame.f_locals["self"]) except KeyError: # we are in a regular function - qualifier = caller_frame.f_globals["__name__"].split(".", 1)[1] + qualifier = caller_frame.f_globals["__name__"].split(".", 1)[-1] else: # we are in a method qualifier = caller_object_type.__name__ func_qualname = qualifier + "." + caller_frame.f_code.co_name @@ -1097,7 +1097,7 @@ def pretty(data, indent=''): ind2 = indent + " " if isinstance(data, dict): ret = indent+"{\n" - for k, v in data.iteritems(): + for k, v in data.items(): ret += ind2 + repr(k) + ": " + pretty(v, ind2).strip() + "\n" ret += indent+"}\n" elif isinstance(data, list) or isinstance(data, tuple): @@ -1186,3 +1186,23 @@ def color(self): c = (len(self.colors) % 15) + 1 self.colors[tid] = c return self.colors[tid] + + +def enableFaulthandler(): + """ Enable faulthandler for all threads. + + If the faulthandler package is available, this function disables and then + re-enables fault handling for all threads (this is necessary to ensure any + new threads are handled correctly), and returns True. + + If faulthandler is not available, then returns False. + """ + try: + import faulthandler + # necessary to disable first or else new threads may not be handled. + faulthandler.disable() + faulthandler.enable(all_threads=True) + return True + except ImportError: + return False + diff --git a/pyqtgraph/dockarea/Container.py b/pyqtgraph/dockarea/Container.py index c3225edf1a..bc0b3648de 100644 --- a/pyqtgraph/dockarea/Container.py +++ b/pyqtgraph/dockarea/Container.py @@ -17,16 +17,20 @@ def container(self): def containerChanged(self, c): self._container = c + if c is None: + self.area = None + else: + self.area = c.area def type(self): return None def insert(self, new, pos=None, neighbor=None): - # remove from existing parent first - new.setParent(None) - if not isinstance(new, list): new = [new] + for n in new: + # remove from existing parent first + n.setParent(None) if neighbor is None: if pos == 'before': index = 0 @@ -40,34 +44,37 @@ def insert(self, new, pos=None, neighbor=None): index += 1 for n in new: - #print "change container", n, " -> ", self - n.containerChanged(self) #print "insert", n, " -> ", self, index self._insertItem(n, index) + #print "change container", n, " -> ", self + n.containerChanged(self) index += 1 n.sigStretchChanged.connect(self.childStretchChanged) #print "child added", self self.updateStretch() def apoptose(self, propagate=True): - ##if there is only one (or zero) item in this container, disappear. + # if there is only one (or zero) item in this container, disappear. + # if propagate is True, then also attempt to apoptose parent containers. cont = self._container c = self.count() if c > 1: return - if self.count() == 1: ## if there is one item, give it to the parent container (unless this is the top) - if self is self.area.topContainer: + if c == 1: ## if there is one item, give it to the parent container (unless this is the top) + ch = self.widget(0) + if (self.area is not None and self is self.area.topContainer and not isinstance(ch, Container)) or self.container() is None: return - self.container().insert(self.widget(0), 'before', self) + self.container().insert(ch, 'before', self) #print "apoptose:", self self.close() if propagate and cont is not None: cont.apoptose() - + def close(self): - self.area = None - self._container = None self.setParent(None) + if self.area is not None and self.area.topContainer is self: + self.area.topContainer = None + self.containerChanged(None) def childEvent(self, ev): ch = ev.child() @@ -92,7 +99,6 @@ def updateStretch(self): ###Set the stretch values for this container to reflect its contents pass - def stretch(self): """Return the stretch factors for this container""" return self._stretch diff --git a/pyqtgraph/dockarea/Dock.py b/pyqtgraph/dockarea/Dock.py index 4493d075e1..15c87652e3 100644 --- a/pyqtgraph/dockarea/Dock.py +++ b/pyqtgraph/dockarea/Dock.py @@ -5,10 +5,10 @@ from ..python2_3 import asUnicode class Dock(QtGui.QWidget, DockDrop): - + sigStretchChanged = QtCore.Signal() sigClosed = QtCore.Signal(object) - + def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, autoOrientation=True, closable=False): QtGui.QWidget.__init__(self) DockDrop.__init__(self) @@ -36,6 +36,7 @@ def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, self.widgetArea.setLayout(self.layout) self.widgetArea.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) self.widgets = [] + self._container = None self.currentRow = 0 #self.titlePos = 'top' self.raiseOverlay() @@ -67,9 +68,9 @@ def __init__(self, name, area=None, size=(10, 10), widget=None, hideTitle=False, }""" self.setAutoFillBackground(False) self.widgetArea.setStyleSheet(self.hStyle) - + self.setStretch(*size) - + if widget is not None: self.addWidget(widget) @@ -81,34 +82,22 @@ def implements(self, name=None): return ['dock'] else: return name == 'dock' - + def setStretch(self, x=None, y=None): """ Set the 'target' size for this Dock. The actual size will be determined by comparing this Dock's stretch value to the rest of the docks it shares space with. """ - #print "setStretch", self, x, y - #self._stretch = (x, y) if x is None: x = 0 if y is None: y = 0 - #policy = self.sizePolicy() - #policy.setHorizontalStretch(x) - #policy.setVerticalStretch(y) - #self.setSizePolicy(policy) self._stretch = (x, y) self.sigStretchChanged.emit() - #print "setStretch", self, x, y, self.stretch() def stretch(self): - #policy = self.sizePolicy() - #return policy.horizontalStretch(), policy.verticalStretch() return self._stretch - - #def stretch(self): - #return self._stretch def hideTitleBar(self): """ @@ -120,7 +109,7 @@ def hideTitleBar(self): if 'center' in self.allowedAreas: self.allowedAreas.remove('center') self.updateStyle() - + def showTitleBar(self): """ Show the title bar for this Dock. @@ -141,7 +130,7 @@ def setTitle(self, text): Sets the text displayed in title bar for this Dock. """ self.label.setText(text) - + def setOrientation(self, o='auto', force=False): """ Sets the orientation of the title bar for this Dock. @@ -149,7 +138,6 @@ def setOrientation(self, o='auto', force=False): By default ('auto'), the orientation is determined based on the aspect ratio of the Dock. """ - #print self.name(), "setOrientation", o, force if o == 'auto' and self.autoOrient: if self.container().type() == 'tab': o = 'horizontal' @@ -161,22 +149,19 @@ def setOrientation(self, o='auto', force=False): self.orientation = o self.label.setOrientation(o) self.updateStyle() - + def updateStyle(self): ## updates orientation and appearance of title bar - #print self.name(), "update style:", self.orientation, self.moveLabel, self.label.isVisible() if self.labelHidden: self.widgetArea.setStyleSheet(self.nStyle) elif self.orientation == 'vertical': self.label.setOrientation('vertical') if self.moveLabel: - #print self.name(), "reclaim label" self.topLayout.addWidget(self.label, 1, 0) self.widgetArea.setStyleSheet(self.vStyle) else: self.label.setOrientation('horizontal') if self.moveLabel: - #print self.name(), "reclaim label" self.topLayout.addWidget(self.label, 0, 1) self.widgetArea.setStyleSheet(self.hStyle) @@ -187,9 +172,6 @@ def resizeEvent(self, ev): def name(self): return self._name - def container(self): - return self._container - def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1): """ Add a new widget to the interior of this Dock. @@ -202,39 +184,46 @@ def addWidget(self, widget, row=None, col=0, rowspan=1, colspan=1): self.layout.addWidget(widget, row, col, rowspan, colspan) self.raiseOverlay() - def startDrag(self): self.drag = QtGui.QDrag(self) mime = QtCore.QMimeData() - #mime.setPlainText("asd") self.drag.setMimeData(mime) self.widgetArea.setStyleSheet(self.dragStyle) self.update() action = self.drag.exec_() self.updateStyle() - + def float(self): self.area.floatDock(self) + def container(self): + return self._container + def containerChanged(self, c): - #print self.name(), "container changed" + if self._container is not None: + # ask old container to close itself if it is no longer needed + self._container.apoptose() self._container = c - if c.type() != 'tab': - self.moveLabel = True - self.label.setDim(False) + if c is None: + self.area = None else: - self.moveLabel = False - - self.setOrientation(force=True) - + self.area = c.area + if c.type() != 'tab': + self.moveLabel = True + self.label.setDim(False) + else: + self.moveLabel = False + + self.setOrientation(force=True) + def raiseDock(self): """If this Dock is stacked underneath others, raise it to the top.""" self.container().raiseDock(self) - def close(self): """Remove this dock from the DockArea it lives inside.""" self.setParent(None) + QtGui.QLabel.close(self.label) self.label.setParent(None) self._container.apoptose() self._container = None @@ -259,10 +248,10 @@ def dropEvent(self, *args): class DockLabel(VerticalLabel): - + sigClicked = QtCore.Signal(object, object) sigCloseClicked = QtCore.Signal() - + def __init__(self, text, dock, showCloseButton): self.dim = False self.fixedWidth = False @@ -289,7 +278,7 @@ def updateStyle(self): fg = '#fff' bg = '#66c' border = '#55B' - + if self.orientation == 'vertical': self.vStyle = """DockLabel { background-color : %s; @@ -323,7 +312,7 @@ def setDim(self, d): if self.dim != d: self.dim = d self.updateStyle() - + def setOrientation(self, o): VerticalLabel.setOrientation(self, o) self.updateStyle() @@ -333,21 +322,21 @@ def mousePressEvent(self, ev): self.pressPos = ev.pos() self.startedDrag = False ev.accept() - + def mouseMoveEvent(self, ev): if not self.startedDrag and (ev.pos() - self.pressPos).manhattanLength() > QtGui.QApplication.startDragDistance(): self.dock.startDrag() ev.accept() - + def mouseReleaseEvent(self, ev): + ev.accept() if not self.startedDrag: self.sigClicked.emit(self, ev) - ev.accept() def mouseDoubleClickEvent(self, ev): if ev.button() == QtCore.Qt.LeftButton: self.dock.float() - + def resizeEvent (self, ev): if self.closeButton: if self.orientation == 'vertical': diff --git a/pyqtgraph/dockarea/DockArea.py b/pyqtgraph/dockarea/DockArea.py index ffe75b61cd..ff3f22abb5 100644 --- a/pyqtgraph/dockarea/DockArea.py +++ b/pyqtgraph/dockarea/DockArea.py @@ -9,9 +9,9 @@ class DockArea(Container, QtGui.QWidget, DockDrop): - def __init__(self, temporary=False, home=None): + def __init__(self, parent=None, temporary=False, home=None): Container.__init__(self, self) - QtGui.QWidget.__init__(self) + QtGui.QWidget.__init__(self, parent=parent) DockDrop.__init__(self, allowedAreas=['left', 'right', 'top', 'bottom']) self.layout = QtGui.QVBoxLayout() self.layout.setContentsMargins(0,0,0,0) @@ -46,6 +46,10 @@ def addDock(self, dock=None, position='bottom', relativeTo=None, **kwds): """ if dock is None: dock = Dock(**kwds) + + # store original area that the dock will return to when un-floated + if not self.temporary: + dock.orig_area = self ## Determine the container to insert this dock into. @@ -61,6 +65,8 @@ def addDock(self, dock=None, position='bottom', relativeTo=None, **kwds): if isinstance(relativeTo, basestring): relativeTo = self.docks[relativeTo] container = self.getContainer(relativeTo) + if container is None: + raise TypeError("Dock %s is not contained in a DockArea; cannot add another dock relative to it." % relativeTo) neighbor = relativeTo ## what container type do we need? @@ -98,7 +104,6 @@ def addDock(self, dock=None, position='bottom', relativeTo=None, **kwds): #print "request insert", dock, insertPos, neighbor old = dock.container() container.insert(dock, insertPos, neighbor) - dock.area = self self.docks[dock.name()] = dock if old is not None: old.apoptose() @@ -142,23 +147,19 @@ def addContainer(self, typ, obj): def insert(self, new, pos=None, neighbor=None): if self.topContainer is not None: + # Adding new top-level container; addContainer() should + # take care of giving the old top container a new home. self.topContainer.containerChanged(None) self.layout.addWidget(new) + new.containerChanged(self) self.topContainer = new - #print self, "set top:", new - new._container = self self.raiseOverlay() - #print "Insert top:", new def count(self): if self.topContainer is None: return 0 return 1 - - #def paintEvent(self, ev): - #self.drawDockOverlay() - def resizeEvent(self, ev): self.resizeOverlay(self.size()) @@ -180,7 +181,6 @@ def floatDock(self, dock): area.win.resize(dock.size()) area.moveDock(dock, 'top', None) - def removeTempArea(self, area): self.tempAreas.remove(area) #print "close window", area.window() @@ -212,14 +212,20 @@ def childState(self, obj): childs.append(self.childState(obj.widget(i))) return (obj.type(), childs, obj.saveState()) - - def restoreState(self, state): + def restoreState(self, state, missing='error', extra='bottom'): """ Restore Dock configuration as generated by saveState. - Note that this function does not create any Docks--it will only + This function does not create any Docks--it will only restore the arrangement of an existing set of Docks. + By default, docks that are described in *state* but do not exist + in the dock area will cause an exception to be raised. This behavior + can be changed by setting *missing* to 'ignore' or 'create'. + + Extra docks that are in the dockarea but that are not mentioned in + *state* will be added to the bottom of the dockarea, unless otherwise + specified by the *extra* argument. """ ## 1) make dict of all docks and list of existing containers @@ -229,17 +235,22 @@ def restoreState(self, state): ## 2) create container structure, move docks into new containers if state['main'] is not None: - self.buildFromState(state['main'], docks, self) + self.buildFromState(state['main'], docks, self, missing=missing) ## 3) create floating areas, populate for s in state['float']: a = self.addTempArea() - a.buildFromState(s[0]['main'], docks, a) + a.buildFromState(s[0]['main'], docks, a, missing=missing) a.win.setGeometry(*s[1]) + a.apoptose() # ask temp area to close itself if it is empty - ## 4) Add any remaining docks to the bottom + ## 4) Add any remaining docks to a float for d in docks.values(): - self.moveDock(d, 'below', None) + if extra == 'float': + a = self.addTempArea() + a.addDock(d, 'below') + else: + self.moveDock(d, extra, None) #print "\nKill old containers:" ## 5) kill old containers @@ -248,8 +259,7 @@ def restoreState(self, state): for a in oldTemps: a.apoptose() - - def buildFromState(self, state, docks, root, depth=0): + def buildFromState(self, state, docks, root, depth=0, missing='error'): typ, contents, state = state pfx = " " * depth if typ == 'dock': @@ -257,7 +267,15 @@ def buildFromState(self, state, docks, root, depth=0): obj = docks[contents] del docks[contents] except KeyError: - raise Exception('Cannot restore dock state; no dock with name "%s"' % contents) + if missing == 'error': + raise Exception('Cannot restore dock state; no dock with name "%s"' % contents) + elif missing == 'create': + obj = Dock(name=contents) + elif missing == 'ignore': + return + else: + raise ValueError('"missing" argument must be one of "error", "create", or "ignore".') + else: obj = self.makeContainer(typ) @@ -266,10 +284,11 @@ def buildFromState(self, state, docks, root, depth=0): if typ != 'dock': for o in contents: - self.buildFromState(o, docks, obj, depth+1) + self.buildFromState(o, docks, obj, depth+1, missing=missing) + # remove this container if possible. (there are valid situations when a restore will + # generate empty containers, such as when using missing='ignore') obj.apoptose(propagate=False) - obj.restoreState(state) ## this has to be done later? - + obj.restoreState(state) ## this has to be done later? def findAll(self, obj=None, c=None, d=None): if obj is None: @@ -295,14 +314,15 @@ def findAll(self, obj=None, c=None, d=None): d.update(d2) return (c, d) - def apoptose(self): + def apoptose(self, propagate=True): + # remove top container if possible, close this area if it is temporary. #print "apoptose area:", self.temporary, self.topContainer, self.topContainer.count() - if self.topContainer.count() == 0: + if self.topContainer is None or self.topContainer.count() == 0: self.topContainer = None if self.temporary: self.home.removeTempArea(self) #self.close() - + def clear(self): docks = self.findAll()[1] for dock in docks.values(): @@ -322,12 +342,44 @@ def dragLeaveEvent(self, *args): def dropEvent(self, *args): DockDrop.dropEvent(self, *args) + def printState(self, state=None, name='Main'): + # for debugging + if state is None: + state = self.saveState() + print("=== %s dock area ===" % name) + if state['main'] is None: + print(" (empty)") + else: + self._printAreaState(state['main']) + for i, float in enumerate(state['float']): + self.printState(float[0], name='float %d' % i) + + def _printAreaState(self, area, indent=0): + if area[0] == 'dock': + print(" " * indent + area[0] + " " + str(area[1:])) + return + else: + print(" " * indent + area[0]) + for ch in area[1]: + self._printAreaState(ch, indent+1) -class TempAreaWindow(QtGui.QMainWindow): + + +class TempAreaWindow(QtGui.QWidget): def __init__(self, area, **kwargs): - QtGui.QMainWindow.__init__(self, **kwargs) - self.setCentralWidget(area) + QtGui.QWidget.__init__(self, **kwargs) + self.layout = QtGui.QGridLayout() + self.setLayout(self.layout) + self.layout.setContentsMargins(0, 0, 0, 0) + self.dockarea = area + self.layout.addWidget(area) - def closeEvent(self, *args, **kwargs): - self.centralWidget().clear() - QtGui.QMainWindow.closeEvent(self, *args, **kwargs) + def closeEvent(self, *args): + # restore docks to their original area + docks = self.dockarea.findAll()[1] + for dock in docks.values(): + if hasattr(dock, 'orig_area'): + dock.orig_area.addDock(dock, ) + # clear dock area, and close remaining docks + self.dockarea.clear() + QtGui.QWidget.closeEvent(self, *args) diff --git a/pyqtgraph/dockarea/tests/test_dock.py b/pyqtgraph/dockarea/tests/test_dock.py index 949f3f0eca..3fb47075ab 100644 --- a/pyqtgraph/dockarea/tests/test_dock.py +++ b/pyqtgraph/dockarea/tests/test_dock.py @@ -14,3 +14,15 @@ def test_dock(): assert dock.name() == name # no surprises in return type. assert type(dock.name()) == type(name) + +def test_closable_dock(): + name = "Test close dock" + dock = da.Dock(name=name, closable=True) + + assert dock.label.closeButton != None + +def test_hide_title_dock(): + name = "Test hide title dock" + dock = da.Dock(name=name, hideTitle=True) + + assert dock.labelHidden == True diff --git a/pyqtgraph/dockarea/tests/test_dockarea.py b/pyqtgraph/dockarea/tests/test_dockarea.py new file mode 100644 index 0000000000..9575c29803 --- /dev/null +++ b/pyqtgraph/dockarea/tests/test_dockarea.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- + +import pytest +import pyqtgraph as pg +from pyqtgraph.ordereddict import OrderedDict +pg.mkQApp() + +import pyqtgraph.dockarea as da + +def test_dockarea(): + a = da.DockArea() + d1 = da.Dock("dock 1") + a.addDock(d1, 'left') + + assert a.topContainer is d1.container() + assert d1.container().container() is a + assert d1.area is a + assert a.topContainer.widget(0) is d1 + + d2 = da.Dock("dock 2") + a.addDock(d2, 'right') + + assert a.topContainer is d1.container() + assert a.topContainer is d2.container() + assert d1.container().container() is a + assert d2.container().container() is a + assert d2.area is a + assert a.topContainer.widget(0) is d1 + assert a.topContainer.widget(1) is d2 + + d3 = da.Dock("dock 3") + a.addDock(d3, 'bottom') + + assert a.topContainer is d3.container() + assert d2.container().container() is d3.container() + assert d1.container().container() is d3.container() + assert d1.container().container().container() is a + assert d2.container().container().container() is a + assert d3.container().container() is a + assert d3.area is a + assert d2.area is a + assert a.topContainer.widget(0) is d1.container() + assert a.topContainer.widget(1) is d3 + + d4 = da.Dock("dock 4") + a.addDock(d4, 'below', d3) + + assert d4.container().type() == 'tab' + assert d4.container() is d3.container() + assert d3.container().container() is d2.container().container() + assert d4.area is a + a.printState() + + # layout now looks like: + # vcontainer + # hcontainer + # dock 1 + # dock 2 + # tcontainer + # dock 3 + # dock 4 + + # test save/restore state + state = a.saveState() + a2 = da.DockArea() + # default behavior is to raise exception if docks are missing + with pytest.raises(Exception): + a2.restoreState(state) + + # test restore with ignore missing + a2.restoreState(state, missing='ignore') + assert a2.topContainer is None + + # test restore with auto-create + a2.restoreState(state, missing='create') + assert a2.saveState() == state + a2.printState() + + # double-check that state actually matches the output of saveState() + c1 = a2.topContainer + assert c1.type() == 'vertical' + c2 = c1.widget(0) + c3 = c1.widget(1) + assert c2.type() == 'horizontal' + assert c2.widget(0).name() == 'dock 1' + assert c2.widget(1).name() == 'dock 2' + assert c3.type() == 'tab' + assert c3.widget(0).name() == 'dock 3' + assert c3.widget(1).name() == 'dock 4' + + # test restore with docks already present + a3 = da.DockArea() + a3docks = [] + for i in range(1, 5): + dock = da.Dock('dock %d' % i) + a3docks.append(dock) + a3.addDock(dock, 'right') + a3.restoreState(state) + assert a3.saveState() == state + + # test restore with extra docks present + a3 = da.DockArea() + a3docks = [] + for i in [1, 2, 5, 4, 3]: + dock = da.Dock('dock %d' % i) + a3docks.append(dock) + a3.addDock(dock, 'left') + a3.restoreState(state) + a3.printState() + + + # test a more complex restore + a4 = da.DockArea() + state1 = {'float': [], 'main': + ('horizontal', [ + ('vertical', [ + ('horizontal', [ + ('tab', [ + ('dock', 'dock1', {}), + ('dock', 'dock2', {}), + ('dock', 'dock3', {}), + ('dock', 'dock4', {}) + ], {'index': 1}), + ('vertical', [ + ('dock', 'dock5', {}), + ('horizontal', [ + ('dock', 'dock6', {}), + ('dock', 'dock7', {}) + ], {'sizes': [184, 363]}) + ], {'sizes': [355, 120]}) + ], {'sizes': [9, 552]}) + ], {'sizes': [480]}), + ('dock', 'dock8', {}) + ], {'sizes': [566, 69]}) + } + + state2 = {'float': [], 'main': + ('horizontal', [ + ('vertical', [ + ('horizontal', [ + ('dock', 'dock2', {}), + ('vertical', [ + ('dock', 'dock5', {}), + ('horizontal', [ + ('dock', 'dock6', {}), + ('dock', 'dock7', {}) + ], {'sizes': [492, 485]}) + ], {'sizes': [936, 0]}) + ], {'sizes': [172, 982]}) + ], {'sizes': [941]}), + ('vertical', [ + ('dock', 'dock8', {}), + ('dock', 'dock4', {}), + ('dock', 'dock1', {}) + ], {'sizes': [681, 225, 25]}) + ], {'sizes': [1159, 116]})} + + a4.restoreState(state1, missing='create') + # dock3 not mentioned in restored state; stays in dockarea by default + c, d = a4.findAll() + assert d['dock3'].area is a4 + + a4.restoreState(state2, missing='ignore', extra='float') + a4.printState() + + c, d = a4.findAll() + # dock3 not mentioned in restored state; goes to float due to `extra` argument + assert d['dock3'].area is not a4 + assert d['dock1'].container() is d['dock4'].container() is d['dock8'].container() + assert d['dock6'].container() is d['dock7'].container() + assert a4 is d['dock2'].area is d['dock2'].container().container().container() + assert a4 is d['dock5'].area is d['dock5'].container().container().container().container() + + # States should be the same with two exceptions: + # dock3 is in a float because it does not appear in state2 + # a superfluous vertical splitter in state2 has been removed + state4 = a4.saveState() + state4['main'][1][0] = state4['main'][1][0][1][0] + assert clean_state(state4['main']) == clean_state(state2['main']) + + +def clean_state(state): + # return state dict with sizes removed + ch = [clean_state(x) for x in state[1]] if isinstance(state[1], list) else state[1] + state = (state[0], ch, {}) + + +if __name__ == '__main__': + test_dockarea() diff --git a/pyqtgraph/exporters/CSVExporter.py b/pyqtgraph/exporters/CSVExporter.py index b87f0182e8..33c6ec6958 100644 --- a/pyqtgraph/exporters/CSVExporter.py +++ b/pyqtgraph/exporters/CSVExporter.py @@ -1,7 +1,9 @@ +# -*- coding: utf-8 -*- from ..Qt import QtGui, QtCore from .Exporter import Exporter from ..parametertree import Parameter from .. import PlotItem +from ..python2_3 import asUnicode __all__ = ['CSVExporter'] @@ -29,7 +31,6 @@ def export(self, fileName=None): self.fileSaveDialog(filter=["*.csv", "*.tsv"]) return - fd = open(fileName, 'w') data = [] header = [] @@ -55,28 +56,29 @@ def export(self, fileName=None): sep = ',' else: sep = '\t' - - fd.write(sep.join(header) + '\n') - i = 0 - numFormat = '%%0.%dg' % self.params['precision'] - numRows = max([len(d[0]) for d in data]) - for i in range(numRows): - for j, d in enumerate(data): - # write x value if this is the first column, or if we want x - # for all rows - if appendAllX or j == 0: - if d is not None and i < len(d[0]): - fd.write(numFormat % d[0][i] + sep) + + with open(fileName, 'w') as fd: + fd.write(sep.join(map(asUnicode, header)) + '\n') + i = 0 + numFormat = '%%0.%dg' % self.params['precision'] + numRows = max([len(d[0]) for d in data]) + for i in range(numRows): + for j, d in enumerate(data): + # write x value if this is the first column, or if we want + # x for all rows + if appendAllX or j == 0: + if d is not None and i < len(d[0]): + fd.write(numFormat % d[0][i] + sep) + else: + fd.write(' %s' % sep) + + # write y value + if d is not None and i < len(d[1]): + fd.write(numFormat % d[1][i] + sep) else: fd.write(' %s' % sep) - - # write y value - if d is not None and i < len(d[1]): - fd.write(numFormat % d[1][i] + sep) - else: - fd.write(' %s' % sep) - fd.write('\n') - fd.close() + fd.write('\n') + CSVExporter.register() diff --git a/pyqtgraph/exporters/HDF5Exporter.py b/pyqtgraph/exporters/HDF5Exporter.py index cc8b5733e7..2a2ac19c39 100644 --- a/pyqtgraph/exporters/HDF5Exporter.py +++ b/pyqtgraph/exporters/HDF5Exporter.py @@ -42,16 +42,29 @@ def export(self, fileName=None): dsname = self.params['Name'] fd = h5py.File(fileName, 'a') # forces append to file... 'w' doesn't seem to "delete/overwrite" data = [] - + appendAllX = self.params['columnMode'] == '(x,y) per plot' - for i,c in enumerate(self.item.curves): - d = c.getData() - if appendAllX or i == 0: - data.append(d[0]) - data.append(d[1]) - - fdata = numpy.array(data).astype('double') - dset = fd.create_dataset(dsname, data=fdata) + # Check if the arrays are ragged + len_first = len(self.item.curves[0].getData()[0]) if self.item.curves[0] else None + ragged = any(len(i.getData()[0]) != len_first for i in self.item.curves) + + if ragged: + dgroup = fd.create_group(dsname) + for i, c in enumerate(self.item.curves): + d = c.getData() + fdata = numpy.array([d[0], d[1]]).astype('double') + cname = c.name() if c.name() is not None else str(i) + dset = dgroup.create_dataset(cname, data=fdata) + else: + for i, c in enumerate(self.item.curves): + d = c.getData() + if appendAllX or i == 0: + data.append(d[0]) + data.append(d[1]) + + fdata = numpy.array(data).astype('double') + dset = fd.create_dataset(dsname, data=fdata) + fd.close() if HAVE_HDF5: diff --git a/pyqtgraph/exporters/ImageExporter.py b/pyqtgraph/exporters/ImageExporter.py index 78d9310654..cacddee103 100644 --- a/pyqtgraph/exporters/ImageExporter.py +++ b/pyqtgraph/exporters/ImageExporter.py @@ -1,6 +1,6 @@ from .Exporter import Exporter from ..parametertree import Parameter -from ..Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +from ..Qt import QtGui, QtCore, QtSvg, QT_LIB from .. import functions as fn import numpy as np @@ -23,10 +23,11 @@ def __init__(self, item): bg.setAlpha(0) self.params = Parameter(name='params', type='group', children=[ - {'name': 'width', 'type': 'int', 'value': tr.width(), 'limits': (0, None)}, - {'name': 'height', 'type': 'int', 'value': tr.height(), 'limits': (0, None)}, + {'name': 'width', 'type': 'int', 'value': int(tr.width()), 'limits': (0, None)}, + {'name': 'height', 'type': 'int', 'value': int(tr.height()), 'limits': (0, None)}, {'name': 'antialias', 'type': 'bool', 'value': True}, {'name': 'background', 'type': 'color', 'value': bg}, + {'name': 'invertValue', 'type': 'bool', 'value': False} ]) self.params.param('width').sigValueChanged.connect(self.widthChanged) self.params.param('height').sigValueChanged.connect(self.heightChanged) @@ -34,46 +35,50 @@ def __init__(self, item): def widthChanged(self): sr = self.getSourceRect() ar = float(sr.height()) / sr.width() - self.params.param('height').setValue(self.params['width'] * ar, blockSignal=self.heightChanged) + self.params.param('height').setValue(int(self.params['width'] * ar), blockSignal=self.heightChanged) def heightChanged(self): sr = self.getSourceRect() ar = float(sr.width()) / sr.height() - self.params.param('width').setValue(self.params['height'] * ar, blockSignal=self.widthChanged) + self.params.param('width').setValue(int(self.params['height'] * ar), blockSignal=self.widthChanged) def parameters(self): return self.params - + + @staticmethod + def getSupportedImageFormats(): + filter = ["*."+f.data().decode('utf-8') for f in QtGui.QImageWriter.supportedImageFormats()] + preferred = ['*.png', '*.tif', '*.jpg'] + for p in preferred[::-1]: + if p in filter: + filter.remove(p) + filter.insert(0, p) + return filter + def export(self, fileName=None, toBytes=False, copy=False): if fileName is None and not toBytes and not copy: - if USE_PYSIDE: - filter = ["*."+str(f) for f in QtGui.QImageWriter.supportedImageFormats()] - else: - filter = ["*."+bytes(f).decode('utf-8') for f in QtGui.QImageWriter.supportedImageFormats()] - preferred = ['*.png', '*.tif', '*.jpg'] - for p in preferred[::-1]: - if p in filter: - filter.remove(p) - filter.insert(0, p) + filter = self.getSupportedImageFormats() self.fileSaveDialog(filter=filter) return - - targetRect = QtCore.QRect(0, 0, self.params['width'], self.params['height']) - sourceRect = self.getSourceRect() - - - #self.png = QtGui.QImage(targetRect.size(), QtGui.QImage.Format_ARGB32) - #self.png.fill(pyqtgraph.mkColor(self.params['background'])) - w, h = self.params['width'], self.params['height'] + + 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)) - bg = np.empty((self.params['width'], self.params['height'], 4), dtype=np.ubyte) + 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() + + bg = np.empty((h, w, 4), dtype=np.ubyte) color = self.params['background'] bg[:,:,0] = color.blue() bg[:,:,1] = color.green() bg[:,:,2] = color.red() bg[:,:,3] = color.alpha() - self.png = fn.makeQImage(bg, alpha=True) + + self.png = fn.makeQImage(bg, alpha=True, copy=False, transpose=False) + self.bg = bg ## set resolution of image: origTargetRect = self.getTargetRect() @@ -91,12 +96,18 @@ def export(self, fileName=None, toBytes=False, copy=False): self.setExportMode(False) painter.end() + if self.params['invertValue']: + mn = bg[...,:3].min(axis=2) + mx = bg[...,:3].max(axis=2) + d = (255 - mx) - mn + bg[...,:3] += d[...,np.newaxis] + if copy: QtGui.QApplication.clipboard().setImage(self.png) elif toBytes: return self.png else: - self.png.save(fileName) + return self.png.save(fileName) ImageExporter.register() diff --git a/pyqtgraph/exporters/Matplotlib.py b/pyqtgraph/exporters/Matplotlib.py index 8cec1cef5c..dedc2b8741 100644 --- a/pyqtgraph/exporters/Matplotlib.py +++ b/pyqtgraph/exporters/Matplotlib.py @@ -43,7 +43,7 @@ def cleanAxes(self, axl): for ax in axl: if ax is None: continue - for loc, spine in ax.spines.iteritems(): + for loc, spine in ax.spines.items(): if loc in ['left', 'bottom']: pass elif loc in ['right', 'top']: @@ -124,5 +124,4 @@ def __getattr__(self, attr): def closeEvent(self, ev): MatplotlibExporter.windows.remove(self) - - + self.deleteLater() diff --git a/pyqtgraph/exporters/SVGExporter.py b/pyqtgraph/exporters/SVGExporter.py index ccf921654e..6f0035bb51 100644 --- a/pyqtgraph/exporters/SVGExporter.py +++ b/pyqtgraph/exporters/SVGExporter.py @@ -1,7 +1,7 @@ from .Exporter import Exporter from ..python2_3 import asUnicode from ..parametertree import Parameter -from ..Qt import QtGui, QtCore, QtSvg, USE_PYSIDE +from ..Qt import QtGui, QtCore, QtSvg, QT_LIB from .. import debug from .. import functions as fn import re @@ -23,7 +23,8 @@ def __init__(self, item): #{'name': 'height', 'type': 'float', 'value': tr.height(), 'limits': (0, None)}, #{'name': 'viewbox clipping', 'type': 'bool', 'value': True}, #{'name': 'normalize coordinates', 'type': 'bool', 'value': True}, - #{'name': 'normalize line width', 'type': 'bool', 'value': True}, + {'name': 'scaling stroke', 'type': 'bool', 'value': False, 'tip': "If False, strokes are non-scaling, " + "which means that they appear the same width on screen regardless of how they are scaled or how the view is zoomed."}, ]) #self.params.param('width').sigValueChanged.connect(self.widthChanged) #self.params.param('height').sigValueChanged.connect(self.heightChanged) @@ -49,7 +50,8 @@ def export(self, fileName=None, toBytes=False, copy=False): ## Qt's SVG generator is not complete. (notably, it lacks clipping) ## Instead, we will use Qt to generate SVG for each item independently, ## then manually reconstruct the entire document. - xml = generateSvg(self.item) + options = {ch.name():ch.value() for ch in self.params.children()} + xml = generateSvg(self.item, options) if toBytes: return xml.encode('UTF-8') @@ -67,12 +69,19 @@ def export(self, fileName=None, toBytes=False, copy=False): pyqtgraph SVG export Generated with Qt and pyqtgraph + """ -def generateSvg(item): +def generateSvg(item, options={}): global xmlHeader try: - node, defs = _generateItemSvg(item) + node, defs = _generateItemSvg(item, options=options) finally: ## reset export mode for all items in the tree if isinstance(item, QtGui.QGraphicsScene): @@ -94,7 +103,7 @@ def generateSvg(item): return xmlHeader + defsXml + node.toprettyxml(indent=' ') + "\n\n" -def _generateItemSvg(item, nodes=None, root=None): +def _generateItemSvg(item, nodes=None, root=None, options={}): ## This function is intended to work around some issues with Qt's SVG generator ## and SVG in general. ## 1) Qt SVG does not implement clipping paths. This is absurd. @@ -169,7 +178,7 @@ def _generateItemSvg(item, nodes=None, root=None): buf = QtCore.QBuffer(arr) svg = QtSvg.QSvgGenerator() svg.setOutputDevice(buf) - dpi = QtGui.QDesktopWidget().physicalDpiX() + dpi = QtGui.QDesktopWidget().logicalDpiX() svg.setResolution(dpi) p = QtGui.QPainter() @@ -178,19 +187,17 @@ def _generateItemSvg(item, nodes=None, root=None): item.setExportMode(True, {'painter': p}) try: p.setTransform(tr) - item.paint(p, QtGui.QStyleOptionGraphicsItem(), None) + opt = QtGui.QStyleOptionGraphicsItem() + if item.flags() & QtGui.QGraphicsItem.ItemUsesExtendedStyleOption: + opt.exposedRect = item.boundingRect() + item.paint(p, opt, None) finally: p.end() ## Can't do this here--we need to wait until all children have painted as well. ## this is taken care of in generateSvg instead. #if hasattr(item, 'setExportMode'): #item.setExportMode(False) - - if USE_PYSIDE: - xmlStr = str(arr) - else: - xmlStr = bytes(arr).decode('utf-8') - doc = xml.parseString(xmlStr) + doc = xml.parseString(arr.data()) try: ## Get top-level group for this item @@ -209,18 +216,8 @@ def _generateItemSvg(item, nodes=None, root=None): ## Get rid of group transformation matrices by applying ## transformation to inner coordinates - correctCoordinates(g1, defs, item) + correctCoordinates(g1, defs, item, options) profiler('correct') - ## make sure g1 has the transformation matrix - #m = (tr.m11(), tr.m12(), tr.m21(), tr.m22(), tr.m31(), tr.m32()) - #g1.setAttribute('transform', "matrix(%f,%f,%f,%f,%f,%f)" % m) - - #print "=================",item,"=====================" - #print g1.toprettyxml(indent=" ", newl='') - - ## Inkscape does not support non-scaling-stroke (this is SVG 1.2, inkscape supports 1.1) - ## So we need to correct anything attempting to use this. - #correctStroke(g1, item, root) ## decide on a name for this item baseName = item.__class__.__name__ @@ -239,15 +236,10 @@ def _generateItemSvg(item, nodes=None, root=None): ## See if this item clips its children if int(item.flags() & item.ItemClipsChildrenToShape) > 0: ## Generate svg for just the path - #if isinstance(root, QtGui.QGraphicsScene): - #path = QtGui.QGraphicsPathItem(item.mapToScene(item.shape())) - #else: - #path = QtGui.QGraphicsPathItem(root.mapToParent(item.mapToItem(root, item.shape()))) path = QtGui.QGraphicsPathItem(item.mapToScene(item.shape())) item.scene().addItem(path) try: - #pathNode = _generateItemSvg(path, root=root).getElementsByTagName('path')[0] - pathNode = _generateItemSvg(path, root=root)[0].getElementsByTagName('path')[0] + pathNode = _generateItemSvg(path, root=root, options=options)[0].getElementsByTagName('path')[0] # assume for this path is empty.. possibly problematic. finally: item.scene().removeItem(path) @@ -267,17 +259,18 @@ def _generateItemSvg(item, nodes=None, root=None): ## Add all child items as sub-elements. childs.sort(key=lambda c: c.zValue()) for ch in childs: - csvg = _generateItemSvg(ch, nodes, root) + csvg = _generateItemSvg(ch, nodes, root, options=options) if csvg is None: continue cg, cdefs = csvg childGroup.appendChild(cg) ### this isn't quite right--some items draw below their parent (good enough for now) defs.extend(cdefs) - + profiler('children') return g1, defs -def correctCoordinates(node, defs, item): + +def correctCoordinates(node, defs, item, options): # TODO: correct gradient coordinates inside defs ## Remove transformation matrices from tags by applying matrix to coordinates inside. @@ -344,6 +337,10 @@ def correctCoordinates(node, defs, item): t = '' nc = fn.transformCoordinates(tr, np.array([[float(x),float(y)]]), transpose=True) newCoords += t+str(nc[0,0])+','+str(nc[0,1])+' ' + # If coords start with L instead of M, then the entire path will not be rendered. + # (This can happen if the first point had nan values in it--Qt will skip it on export) + if newCoords[0] != 'M': + newCoords = 'M' + newCoords[1:] ch.setAttribute('d', newCoords) elif ch.tagName == 'text': removeTransform = False @@ -372,12 +369,16 @@ def correctCoordinates(node, defs, item): ch.setAttribute('font-family', ', '.join([f if ' ' not in f else '"%s"'%f for f in families])) ## correct line widths if needed - if removeTransform and ch.getAttribute('vector-effect') != 'non-scaling-stroke': + if removeTransform and ch.getAttribute('vector-effect') != 'non-scaling-stroke' and grp.getAttribute('stroke-width') != '': w = float(grp.getAttribute('stroke-width')) s = fn.transformCoordinates(tr, np.array([[w,0], [0,0]]), transpose=True) w = ((s[0]-s[1])**2).sum()**0.5 ch.setAttribute('stroke-width', str(w)) + # Remove non-scaling-stroke if requested + if options.get('scaling stroke') is True and ch.getAttribute('vector-effect') == 'non-scaling-stroke': + ch.removeAttribute('vector-effect') + if removeTransform: grp.removeAttribute('transform') diff --git a/pyqtgraph/exporters/tests/test_csv.py b/pyqtgraph/exporters/tests/test_csv.py index 15c6626e20..d6da033bf4 100644 --- a/pyqtgraph/exporters/tests/test_csv.py +++ b/pyqtgraph/exporters/tests/test_csv.py @@ -1,5 +1,5 @@ """ -SVG export test +CSV export test """ from __future__ import division, print_function, absolute_import import pyqtgraph as pg @@ -33,8 +33,9 @@ def test_CSVExporter(): ex = pg.exporters.CSVExporter(plt.plotItem) ex.export(fileName=tempfilename) - r = csv.reader(open(tempfilename, 'r')) - lines = [line for line in r] + with open(tempfilename, 'r') as csv_file: + r = csv.reader(csv_file) + lines = [line for line in r] header = lines.pop(0) assert header == ['myPlot_x', 'myPlot_y', 'x0001', 'y0001', 'x0002', 'y0002'] diff --git a/pyqtgraph/exporters/tests/test_hdf5.py b/pyqtgraph/exporters/tests/test_hdf5.py new file mode 100644 index 0000000000..69bb8ae7d9 --- /dev/null +++ b/pyqtgraph/exporters/tests/test_hdf5.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +import pytest +import pyqtgraph as pg +from pyqtgraph.exporters import HDF5Exporter +import numpy as np +from numpy.testing import assert_equal +import h5py +import os + + +@pytest.fixture +def tmp_h5(tmp_path): + yield tmp_path / "data.h5" + + +@pytest.mark.parametrize("combine", [False, True]) +def test_HDF5Exporter(tmp_h5, combine): + # Basic test of functionality: multiple curves with shared x array. Tests + # both options for stacking the data (columnMode). + x = np.linspace(0, 1, 100) + y1 = np.sin(x) + y2 = np.cos(x) + + plt = pg.plot() + plt.plot(x=x, y=y1) + plt.plot(x=x, y=y2) + + ex = HDF5Exporter(plt.plotItem) + + if combine: + ex.parameters()['columnMode'] = '(x,y,y,y) for all plots' + + ex.export(fileName=tmp_h5) + + with h5py.File(tmp_h5, 'r') as f: + # should be a single dataset with the name of the exporter + dset = f[ex.parameters()['Name']] + assert isinstance(dset, h5py.Dataset) + + if combine: + assert_equal(np.array([x, y1, y2]), dset) + else: + assert_equal(np.array([x, y1, x, y2]), dset) + + +def test_HDF5Exporter_unequal_lengths(tmp_h5): + # Test export with multiple curves of different size. The exporter should + # detect this and create multiple hdf5 datasets under a group. + x1 = np.linspace(0, 1, 10) + y1 = np.sin(x1) + x2 = np.linspace(0, 1, 100) + y2 = np.cos(x2) + + plt = pg.plot() + plt.plot(x=x1, y=y1, name='plot0') + plt.plot(x=x2, y=y2) + + ex = HDF5Exporter(plt.plotItem) + ex.export(fileName=tmp_h5) + + with h5py.File(tmp_h5, 'r') as f: + # should be a group with the name of the exporter + group = f[ex.parameters()['Name']] + assert isinstance(group, h5py.Group) + + # should be a dataset under the group with the name of the PlotItem + assert_equal(np.array([x1, y1]), group['plot0']) + + # should be a dataset under the group with a default name that's the + # index of the curve in the PlotItem + assert_equal(np.array([x2, y2]), group['1']) diff --git a/pyqtgraph/exporters/tests/test_image.py b/pyqtgraph/exporters/tests/test_image.py new file mode 100644 index 0000000000..6f52ecebbc --- /dev/null +++ b/pyqtgraph/exporters/tests/test_image.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +import pyqtgraph as pg +from pyqtgraph.exporters import ImageExporter + +app = pg.mkQApp() + + +def test_ImageExporter_filename_dialog(): + """Tests ImageExporter code path that opens a file dialog. Regression test + for pull request 1133.""" + p = pg.plot() + exp = ImageExporter(p.getPlotItem()) + exp.export() diff --git a/pyqtgraph/exporters/tests/test_svg.py b/pyqtgraph/exporters/tests/test_svg.py index 2261f7df65..629463686c 100644 --- a/pyqtgraph/exporters/tests/test_svg.py +++ b/pyqtgraph/exporters/tests/test_svg.py @@ -28,6 +28,7 @@ def test_plotscene(): ex.export(fileName=tempfilename) # clean up after the test is done os.unlink(tempfilename) + w.close() def test_simple(): tempfilename = tempfile.NamedTemporaryFile(suffix='.svg').name diff --git a/pyqtgraph/flowchart/Flowchart.py b/pyqtgraph/flowchart/Flowchart.py index b623f5c79f..2c7b9d59ca 100644 --- a/pyqtgraph/flowchart/Flowchart.py +++ b/pyqtgraph/flowchart/Flowchart.py @@ -1,15 +1,18 @@ # -*- coding: utf-8 -*- -from ..Qt import QtCore, QtGui, USE_PYSIDE, USE_PYQT5 +from ..Qt import QtCore, QtGui, QT_LIB from .Node import * from ..pgcollections import OrderedDict from ..widgets.TreeWidget import * from .. import FileDialog, DataTreeWidget ## pyside and pyqt use incompatible ui files. -if USE_PYSIDE: +if QT_LIB == 'PySide': from . import FlowchartTemplate_pyside as FlowchartTemplate from . import FlowchartCtrlTemplate_pyside as FlowchartCtrlTemplate -elif USE_PYQT5: +elif QT_LIB == 'PySide2': + from . import FlowchartTemplate_pyside2 as FlowchartTemplate + from . import FlowchartCtrlTemplate_pyside2 as FlowchartCtrlTemplate +elif QT_LIB == 'PyQt5': from . import FlowchartTemplate_pyqt5 as FlowchartTemplate from . import FlowchartCtrlTemplate_pyqt5 as FlowchartCtrlTemplate else: @@ -24,6 +27,7 @@ from .. import dockarea as dockarea from . import FlowchartGraphicsView from .. import functions as fn +from ..python2_3 import asUnicode def strDict(d): return dict([(str(k), v) for k, v in d.items()]) @@ -166,6 +170,8 @@ def terminalRenamed(self, term, oldName): n[oldName].rename(newName) def createNode(self, nodeType, name=None, pos=None): + """Create a new Node and add it to this flowchart. + """ if name is None: n = 0 while True: @@ -179,6 +185,10 @@ def createNode(self, nodeType, name=None, pos=None): return node def addNode(self, node, name, pos=None): + """Add an existing Node to this flowchart. + + See also: createNode() + """ if pos is None: pos = [0, 0] if type(pos) in [QtCore.QPoint, QtCore.QPointF]: @@ -189,13 +199,16 @@ def addNode(self, node, name, pos=None): self.viewBox.addItem(item) item.moveBy(*pos) self._nodes[name] = node - self.widget().addNode(node) + if node is not self.inputNode and node is not self.outputNode: + self.widget().addNode(node) node.sigClosed.connect(self.nodeClosed) node.sigRenamed.connect(self.nodeRenamed) node.sigOutputChanged.connect(self.nodeOutputChanged) self.sigChartChanged.emit(self, 'add', node) def removeNode(self, node): + """Remove a Node from this flowchart. + """ node.close() def nodeClosed(self, node): @@ -233,7 +246,6 @@ def connectTerminals(self, term1, term2): term2 = self.internalTerminal(term2) term1.connectTo(term2) - def process(self, **args): """ Process data through the flowchart, returning the output. @@ -325,7 +337,6 @@ def processOrder(self): #print "DEPS:", deps ## determine correct node-processing order - #deps[self] = [] order = fn.toposort(deps) #print "ORDER1:", order @@ -349,7 +360,6 @@ def processOrder(self): if lastNode is None or ind > lastInd: lastNode = n lastInd = ind - #tdeps[t] = lastNode if lastInd is not None: dels.append((lastInd+1, t)) dels.sort(key=lambda a: a[0], reverse=True) @@ -404,27 +414,25 @@ def nodeOutputChanged(self, startNode): self.inputWasSet = False else: self.sigStateChanged.emit() - - def chartGraphicsItem(self): - """Return the graphicsItem which displays the internals of this flowchart. - (graphicsItem() still returns the external-view item)""" - #return self._chartGraphicsItem + """Return the graphicsItem that displays the internal nodes and + connections of this flowchart. + + Note that the similar method `graphicsItem()` is inherited from Node + and returns the *external* graphical representation of this flowchart.""" return self.viewBox def widget(self): + """Return the control widget for this flowchart. + + This widget provides GUI access to the parameters for each node and a + graphical representation of the flowchart. + """ if self._widget is None: self._widget = FlowchartCtrlWidget(self) self.scene = self._widget.scene() self.viewBox = self._widget.viewBox() - #self._scene = QtGui.QGraphicsScene() - #self._widget.setScene(self._scene) - #self.scene.addItem(self.chartGraphicsItem()) - - #ci = self.chartGraphicsItem() - #self.viewBox.addItem(ci) - #self.viewBox.autoRange() return self._widget def listConnections(self): @@ -437,10 +445,11 @@ def listConnections(self): return conn def saveState(self): + """Return a serializable data structure representing the current state of this flowchart. + """ state = Node.saveState(self) state['nodes'] = [] state['connects'] = [] - #state['terminals'] = self.saveTerminals() for name, node in self._nodes.items(): cls = type(node) @@ -460,6 +469,8 @@ def saveState(self): return state def restoreState(self, state, clear=False): + """Restore the state of this flowchart from a previous call to `saveState()`. + """ self.blockSignals(True) try: if clear: @@ -469,7 +480,6 @@ def restoreState(self, state, clear=False): nodes.sort(key=lambda a: a['pos'][0]) for n in nodes: if n['name'] in self._nodes: - #self._nodes[n['name']].graphicsItem().moveBy(*n['pos']) self._nodes[n['name']].restoreState(n['state']) continue try: @@ -477,7 +487,6 @@ def restoreState(self, state, clear=False): node.restoreState(n['state']) except: printExc("Error creating node %s: (continuing anyway)" % n['name']) - #node.graphicsItem().moveBy(*n['pos']) self.inputNode.restoreState(state.get('inputNode', {})) self.outputNode.restoreState(state.get('outputNode', {})) @@ -490,56 +499,54 @@ def restoreState(self, state, clear=False): print(self._nodes[n1].terminals) print(self._nodes[n2].terminals) printExc("Error connecting terminals %s.%s - %s.%s:" % (n1, t1, n2, t2)) - finally: self.blockSignals(False) - self.sigChartLoaded.emit() self.outputChanged() + self.sigChartLoaded.emit() self.sigStateChanged.emit() - #self.sigOutputChanged.emit() def loadFile(self, fileName=None, startDir=None): + """Load a flowchart (``*.fc``) file. + """ if fileName is None: if startDir is None: startDir = self.filePath if startDir is None: startDir = '.' self.fileDialog = FileDialog(None, "Load Flowchart..", startDir, "Flowchart (*.fc)") - #self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) - #self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) self.fileDialog.show() self.fileDialog.fileSelected.connect(self.loadFile) return ## NOTE: was previously using a real widget for the file dialog's parent, but this caused weird mouse event bugs.. - #fileName = QtGui.QFileDialog.getOpenFileName(None, "Load Flowchart..", startDir, "Flowchart (*.fc)") - fileName = unicode(fileName) + fileName = asUnicode(fileName) state = configfile.readConfigFile(fileName) self.restoreState(state, clear=True) self.viewBox.autoRange() - #self.emit(QtCore.SIGNAL('fileLoaded'), fileName) self.sigFileLoaded.emit(fileName) - + def saveFile(self, fileName=None, startDir=None, suggestedFileName='flowchart.fc'): + """Save this flowchart to a .fc file + """ if fileName is None: if startDir is None: startDir = self.filePath if startDir is None: startDir = '.' self.fileDialog = FileDialog(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") - #self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) + self.fileDialog.setDefaultSuffix("fc") self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) - #self.fileDialog.setDirectory(startDir) self.fileDialog.show() self.fileDialog.fileSelected.connect(self.saveFile) return - #fileName = QtGui.QFileDialog.getSaveFileName(None, "Save Flowchart..", startDir, "Flowchart (*.fc)") - fileName = unicode(fileName) + fileName = asUnicode(fileName) configfile.writeConfigFile(self.saveState(), fileName) self.sigFileSaved.emit(fileName) def clear(self): + """Remove all nodes from this flowchart except the original input/output nodes. + """ for n in list(self._nodes.values()): if n is self.inputNode or n is self.outputNode: continue @@ -552,18 +559,15 @@ def clearTerminals(self): self.inputNode.clearTerminals() self.outputNode.clearTerminals() -#class FlowchartGraphicsItem(QtGui.QGraphicsItem): + class FlowchartGraphicsItem(GraphicsObject): def __init__(self, chart): - #print "FlowchartGraphicsItem.__init__" - #QtGui.QGraphicsItem.__init__(self) GraphicsObject.__init__(self) self.chart = chart ## chart is an instance of Flowchart() self.updateTerminals() def updateTerminals(self): - #print "FlowchartGraphicsItem.updateTerminals" self.terminals = {} bounds = self.boundingRect() inp = self.chart.inputs() @@ -620,7 +624,7 @@ def __init__(self, chart): self.cwWin.resize(1000,800) h = self.ui.ctrlList.header() - if not USE_PYQT5: + if QT_LIB in ['PyQt4', 'PySide']: h.setResizeMode(0, h.Stretch) else: h.setSectionResizeMode(0, h.Stretch) @@ -660,7 +664,7 @@ def loadClicked(self): #self.setCurrentFile(newFile) def fileSaved(self, fileName): - self.setCurrentFile(unicode(fileName)) + self.setCurrentFile(asUnicode(fileName)) self.ui.saveBtn.success("Saved.") def saveClicked(self): @@ -689,7 +693,7 @@ def saveAsClicked(self): #self.setCurrentFile(newFile) def setCurrentFile(self, fileName): - self.currentFileName = unicode(fileName) + self.currentFileName = asUnicode(fileName) if fileName is None: self.ui.fileNameLabel.setText("[ new ]") else: @@ -759,6 +763,10 @@ def select(self, node): item = self.items[node] self.ui.ctrlList.setCurrentItem(item) + def clearSelection(self): + self.ui.ctrlList.selectionModel().clearSelection() + + class FlowchartWidget(dockarea.DockArea): """Includes the actual graphical flowchart and debugging interface""" def __init__(self, chart, ctrl): @@ -829,9 +837,9 @@ def reloadLibrary(self): def buildMenu(self, pos=None): def buildSubMenu(node, rootMenu, subMenus, pos=None): for section, node in node.items(): - menu = QtGui.QMenu(section) - rootMenu.addMenu(menu) - if isinstance(node, OrderedDict): + if isinstance(node, OrderedDict): + menu = QtGui.QMenu(section) + rootMenu.addMenu(menu) buildSubMenu(node, menu, subMenus, pos=pos) subMenus.append(menu) else: @@ -885,7 +893,10 @@ def selectionChanged(self): item = items[0] if hasattr(item, 'node') and isinstance(item.node, Node): n = item.node - self.ctrl.select(n) + if n in self.ctrl.items: + self.ctrl.select(n) + else: + self.ctrl.clearSelection() data = {'outputs': n.outputValues(), 'inputs': n.inputValues()} self.selNameLabel.setText(n.name()) if hasattr(n, 'nodeName'): @@ -933,4 +944,3 @@ def clear(self): class FlowchartNode(Node): pass - diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui b/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui index 0361ad3e65..6a9a203a08 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py index 8afd43f8ff..3d8bcf5653 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt.py @@ -69,7 +69,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) self.loadBtn.setText(_translate("Form", "Load..", None)) self.saveBtn.setText(_translate("Form", "Save", None)) self.saveAsBtn.setText(_translate("Form", "As..", None)) diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py index b661918d2d..958f2aaf5e 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyqt5.py @@ -56,7 +56,7 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.loadBtn.setText(_translate("Form", "Load..")) self.saveBtn.setText(_translate("Form", "Save")) self.saveAsBtn.setText(_translate("Form", "As..")) diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py index b722000e9a..2db10f6aa3 100644 --- a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside.py @@ -55,7 +55,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.loadBtn.setText(QtGui.QApplication.translate("Form", "Load..", None, QtGui.QApplication.UnicodeUTF8)) self.saveBtn.setText(QtGui.QApplication.translate("Form", "Save", None, QtGui.QApplication.UnicodeUTF8)) self.saveAsBtn.setText(QtGui.QApplication.translate("Form", "As..", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside2.py b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside2.py new file mode 100644 index 0000000000..2e7a7a0bc9 --- /dev/null +++ b/pyqtgraph/flowchart/FlowchartCtrlTemplate_pyside2.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'FlowchartCtrlTemplate.ui' +# +# Created: Sun Sep 18 19:16:46 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(217, 499) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setVerticalSpacing(0) + self.gridLayout.setObjectName("gridLayout") + self.loadBtn = QtWidgets.QPushButton(Form) + self.loadBtn.setObjectName("loadBtn") + self.gridLayout.addWidget(self.loadBtn, 1, 0, 1, 1) + self.saveBtn = FeedbackButton(Form) + self.saveBtn.setObjectName("saveBtn") + self.gridLayout.addWidget(self.saveBtn, 1, 1, 1, 2) + self.saveAsBtn = FeedbackButton(Form) + self.saveAsBtn.setObjectName("saveAsBtn") + self.gridLayout.addWidget(self.saveAsBtn, 1, 3, 1, 1) + self.reloadBtn = FeedbackButton(Form) + self.reloadBtn.setCheckable(False) + self.reloadBtn.setFlat(False) + self.reloadBtn.setObjectName("reloadBtn") + self.gridLayout.addWidget(self.reloadBtn, 4, 0, 1, 2) + self.showChartBtn = QtWidgets.QPushButton(Form) + self.showChartBtn.setCheckable(True) + self.showChartBtn.setObjectName("showChartBtn") + self.gridLayout.addWidget(self.showChartBtn, 4, 2, 1, 2) + self.ctrlList = TreeWidget(Form) + self.ctrlList.setObjectName("ctrlList") + self.ctrlList.headerItem().setText(0, "1") + self.ctrlList.header().setVisible(False) + self.ctrlList.header().setStretchLastSection(False) + self.gridLayout.addWidget(self.ctrlList, 3, 0, 1, 4) + self.fileNameLabel = QtWidgets.QLabel(Form) + font = QtGui.QFont() + font.setWeight(75) + font.setBold(True) + self.fileNameLabel.setFont(font) + self.fileNameLabel.setText("") + self.fileNameLabel.setAlignment(QtCore.Qt.AlignCenter) + self.fileNameLabel.setObjectName("fileNameLabel") + self.gridLayout.addWidget(self.fileNameLabel, 0, 1, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.loadBtn.setText(QtWidgets.QApplication.translate("Form", "Load..", None, -1)) + self.saveBtn.setText(QtWidgets.QApplication.translate("Form", "Save", None, -1)) + self.saveAsBtn.setText(QtWidgets.QApplication.translate("Form", "As..", None, -1)) + self.reloadBtn.setText(QtWidgets.QApplication.translate("Form", "Reload Libs", None, -1)) + self.showChartBtn.setText(QtWidgets.QApplication.translate("Form", "Flowchart", None, -1)) + +from ..widgets.FeedbackButton import FeedbackButton +from ..widgets.TreeWidget import TreeWidget diff --git a/pyqtgraph/flowchart/FlowchartTemplate.ui b/pyqtgraph/flowchart/FlowchartTemplate.ui index 8b0c19dad0..22934b916c 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate.ui +++ b/pyqtgraph/flowchart/FlowchartTemplate.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py b/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py index 06b10bfe71..e6084eeea2 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py +++ b/pyqtgraph/flowchart/FlowchartTemplate_pyqt.py @@ -62,7 +62,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py b/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py index ba754305df..448a00ff4f 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py +++ b/pyqtgraph/flowchart/FlowchartTemplate_pyqt5.py @@ -49,7 +49,7 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) from ..widgets.DataTreeWidget import DataTreeWidget from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyside.py b/pyqtgraph/flowchart/FlowchartTemplate_pyside.py index 2c693c6030..47f97f851f 100644 --- a/pyqtgraph/flowchart/FlowchartTemplate_pyside.py +++ b/pyqtgraph/flowchart/FlowchartTemplate_pyside.py @@ -48,7 +48,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/FlowchartTemplate_pyside2.py b/pyqtgraph/flowchart/FlowchartTemplate_pyside2.py new file mode 100644 index 0000000000..2bca5f82f8 --- /dev/null +++ b/pyqtgraph/flowchart/FlowchartTemplate_pyside2.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'FlowchartTemplate.ui' +# +# Created: Sun Sep 18 19:16:03 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(529, 329) + self.selInfoWidget = QtWidgets.QWidget(Form) + self.selInfoWidget.setGeometry(QtCore.QRect(260, 10, 264, 222)) + self.selInfoWidget.setObjectName("selInfoWidget") + self.gridLayout = QtWidgets.QGridLayout(self.selInfoWidget) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setObjectName("gridLayout") + self.selDescLabel = QtWidgets.QLabel(self.selInfoWidget) + self.selDescLabel.setText("") + self.selDescLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop) + self.selDescLabel.setWordWrap(True) + self.selDescLabel.setObjectName("selDescLabel") + self.gridLayout.addWidget(self.selDescLabel, 0, 0, 1, 1) + self.selNameLabel = QtWidgets.QLabel(self.selInfoWidget) + font = QtGui.QFont() + font.setWeight(75) + font.setBold(True) + self.selNameLabel.setFont(font) + self.selNameLabel.setText("") + self.selNameLabel.setObjectName("selNameLabel") + self.gridLayout.addWidget(self.selNameLabel, 0, 1, 1, 1) + self.selectedTree = DataTreeWidget(self.selInfoWidget) + self.selectedTree.setObjectName("selectedTree") + self.selectedTree.headerItem().setText(0, "1") + self.gridLayout.addWidget(self.selectedTree, 1, 0, 1, 2) + self.hoverText = QtWidgets.QTextEdit(Form) + self.hoverText.setGeometry(QtCore.QRect(0, 240, 521, 81)) + self.hoverText.setObjectName("hoverText") + self.view = FlowchartGraphicsView(Form) + self.view.setGeometry(QtCore.QRect(0, 0, 256, 192)) + self.view.setObjectName("view") + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + +from ..flowchart.FlowchartGraphicsView import FlowchartGraphicsView +from ..widgets.DataTreeWidget import DataTreeWidget diff --git a/pyqtgraph/flowchart/Node.py b/pyqtgraph/flowchart/Node.py index c450a9f323..880c04aa49 100644 --- a/pyqtgraph/flowchart/Node.py +++ b/pyqtgraph/flowchart/Node.py @@ -222,7 +222,7 @@ def dependentNodes(self): for t in self.inputs().values(): nodes |= set([i.node() for i in t.inputTerminals()]) return nodes - #return set([t.inputTerminals().node() for t in self.listInputs().itervalues()]) + #return set([t.inputTerminals().node() for t in self.listInputs().values()]) def __repr__(self): return "" % (self.name(), id(self)) @@ -373,7 +373,7 @@ def saveState(self): pos = self.graphicsItem().pos() state = {'pos': (pos.x(), pos.y()), 'bypass': self.isBypassed()} termsEditable = self._allowAddInput | self._allowAddOutput - for term in self._inputs.values() + self._outputs.values(): + for term in list(self._inputs.values()) + list(self._outputs.values()): termsEditable |= term._renamable | term._removable | term._multiable if termsEditable: state['terminals'] = self.saveTerminals() @@ -477,7 +477,7 @@ def __init__(self, node): #self.node.sigTerminalRenamed.connect(self.updateActionMenu) #def setZValue(self, z): - #for t, item in self.terminals.itervalues(): + #for t, item in self.terminals.values(): #item.setZValue(z+1) #GraphicsObject.setZValue(self, z) diff --git a/pyqtgraph/flowchart/library/Data.py b/pyqtgraph/flowchart/library/Data.py index 5236de8d03..b133b15990 100644 --- a/pyqtgraph/flowchart/library/Data.py +++ b/pyqtgraph/flowchart/library/Data.py @@ -2,6 +2,7 @@ from ..Node import Node from ...Qt import QtGui, QtCore import numpy as np +import sys from .common import * from ...SRTTransform import SRTTransform from ...Point import Point @@ -189,31 +190,36 @@ def __init__(self, name): self.ui = QtGui.QWidget() self.layout = QtGui.QGridLayout() - #self.addInBtn = QtGui.QPushButton('+Input') - #self.addOutBtn = QtGui.QPushButton('+Output') self.text = QtGui.QTextEdit() self.text.setTabStopWidth(30) self.text.setPlainText("# Access inputs as args['input_name']\nreturn {'output': None} ## one key per output terminal") - #self.layout.addWidget(self.addInBtn, 0, 0) - #self.layout.addWidget(self.addOutBtn, 0, 1) self.layout.addWidget(self.text, 1, 0, 1, 2) self.ui.setLayout(self.layout) - #QtCore.QObject.connect(self.addInBtn, QtCore.SIGNAL('clicked()'), self.addInput) - #self.addInBtn.clicked.connect(self.addInput) - #QtCore.QObject.connect(self.addOutBtn, QtCore.SIGNAL('clicked()'), self.addOutput) - #self.addOutBtn.clicked.connect(self.addOutput) self.text.focusOutEvent = self.focusOutEvent self.lastText = None def ctrlWidget(self): return self.ui - #def addInput(self): - #Node.addInput(self, 'input', renamable=True) + def setCode(self, code): + # unindent code; this allows nicer inline code specification when + # calling this method. + ind = [] + lines = code.split('\n') + for line in lines: + stripped = line.lstrip() + if len(stripped) > 0: + ind.append(len(line) - len(stripped)) + if len(ind) > 0: + ind = min(ind) + code = '\n'.join([line[ind:] for line in lines]) - #def addOutput(self): - #Node.addOutput(self, 'output', renamable=True) + self.text.clear() + self.text.insertPlainText(code) + + def code(self): + return self.text.toPlainText() def focusOutEvent(self, ev): text = str(self.text.toPlainText()) @@ -233,7 +239,12 @@ def process(self, display=True, **args): fn = "def fn(**args):\n" run = "\noutput=fn(**args)\n" text = fn + "\n".join([" "+l for l in str(self.text.toPlainText()).split('\n')]) + run - exec(text) + if sys.version_info.major == 2: + exec(text) + elif sys.version_info.major == 3: + ldict = locals() + exec(text, globals(), ldict) + output = ldict['output'] except: print("Error processing node: %s" % self.name()) raise @@ -247,10 +258,10 @@ def saveState(self): def restoreState(self, state): Node.restoreState(self, state) - self.text.clear() - self.text.insertPlainText(state['text']) + self.setCode(state['text']) self.restoreTerminals(state['terminals']) self.update() + class ColumnJoinNode(Node): """Concatenates record arrays and/or adds new columns""" @@ -354,3 +365,117 @@ def terminalRenamed(self, term, oldName): self.update() +class Mean(CtrlNode): + """Calculate the mean of an array across an axis. + """ + nodeName = 'Mean' + uiTemplate = [ + ('axis', 'intSpin', {'value': 0, 'min': -1, 'max': 1000000}), + ] + + def processData(self, data): + s = self.stateGroup.state() + ax = None if s['axis'] == -1 else s['axis'] + return data.mean(axis=ax) + + +class Max(CtrlNode): + """Calculate the maximum of an array across an axis. + """ + nodeName = 'Max' + uiTemplate = [ + ('axis', 'intSpin', {'value': 0, 'min': -1, 'max': 1000000}), + ] + + def processData(self, data): + s = self.stateGroup.state() + ax = None if s['axis'] == -1 else s['axis'] + return data.max(axis=ax) + + +class Min(CtrlNode): + """Calculate the minimum of an array across an axis. + """ + nodeName = 'Min' + uiTemplate = [ + ('axis', 'intSpin', {'value': 0, 'min': -1, 'max': 1000000}), + ] + + def processData(self, data): + s = self.stateGroup.state() + ax = None if s['axis'] == -1 else s['axis'] + return data.min(axis=ax) + + +class Stdev(CtrlNode): + """Calculate the standard deviation of an array across an axis. + """ + nodeName = 'Stdev' + uiTemplate = [ + ('axis', 'intSpin', {'value': -0, 'min': -1, 'max': 1000000}), + ] + + def processData(self, data): + s = self.stateGroup.state() + ax = None if s['axis'] == -1 else s['axis'] + return data.std(axis=ax) + + +class Index(CtrlNode): + """Select an index from an array axis. + """ + nodeName = 'Index' + uiTemplate = [ + ('axis', 'intSpin', {'value': 0, 'min': 0, 'max': 1000000}), + ('index', 'intSpin', {'value': 0, 'min': 0, 'max': 1000000}), + ] + + def processData(self, data): + s = self.stateGroup.state() + ax = s['axis'] + ind = s['index'] + if ax == 0: + # allow support for non-ndarray sequence types + return data[ind] + else: + return data.take(ind, axis=ax) + + +class Slice(CtrlNode): + """Select a slice from an array axis. + """ + nodeName = 'Slice' + uiTemplate = [ + ('axis', 'intSpin', {'value': 0, 'min': 0, 'max': 1e6}), + ('start', 'intSpin', {'value': 0, 'min': -1e6, 'max': 1e6}), + ('stop', 'intSpin', {'value': -1, 'min': -1e6, 'max': 1e6}), + ('step', 'intSpin', {'value': 1, 'min': -1e6, 'max': 1e6}), + ] + + def processData(self, data): + s = self.stateGroup.state() + ax = s['axis'] + start = s['start'] + stop = s['stop'] + step = s['step'] + if ax == 0: + # allow support for non-ndarray sequence types + return data[start:stop:step] + else: + sl = [slice(None) for i in range(data.ndim)] + sl[ax] = slice(start, stop, step) + return data[sl] + + +class AsType(CtrlNode): + """Convert an array to a different dtype. + """ + nodeName = 'AsType' + uiTemplate = [ + ('dtype', 'combo', {'values': ['float', 'int', 'float32', 'float64', 'float128', 'int8', 'int16', 'int32', 'int64', 'uint8', 'uint16', 'uint32', 'uint64'], 'index': 0}), + ] + + def processData(self, data): + s = self.stateGroup.state() + return data.astype(s['dtype']) + diff --git a/pyqtgraph/flowchart/library/Filters.py b/pyqtgraph/flowchart/library/Filters.py index 9392b037d7..9a7fa40147 100644 --- a/pyqtgraph/flowchart/library/Filters.py +++ b/pyqtgraph/flowchart/library/Filters.py @@ -38,7 +38,7 @@ class Bessel(CtrlNode): nodeName = 'BesselFilter' uiTemplate = [ ('band', 'combo', {'values': ['lowpass', 'highpass'], 'index': 0}), - ('cutoff', 'spin', {'value': 1000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('cutoff', 'spin', {'value': 1000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), ('order', 'intSpin', {'value': 4, 'min': 1, 'max': 16}), ('bidir', 'check', {'checked': True}) ] @@ -57,10 +57,10 @@ class Butterworth(CtrlNode): nodeName = 'ButterworthFilter' uiTemplate = [ ('band', 'combo', {'values': ['lowpass', 'highpass'], 'index': 0}), - ('wPass', 'spin', {'value': 1000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), - ('wStop', 'spin', {'value': 2000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), - ('gPass', 'spin', {'value': 2.0, 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), - ('gStop', 'spin', {'value': 20.0, 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), + ('wPass', 'spin', {'value': 1000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('wStop', 'spin', {'value': 2000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('gPass', 'spin', {'value': 2.0, 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), + ('gStop', 'spin', {'value': 20.0, 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), ('bidir', 'check', {'checked': True}) ] @@ -78,14 +78,14 @@ class ButterworthNotch(CtrlNode): """Butterworth notch filter""" nodeName = 'ButterworthNotchFilter' uiTemplate = [ - ('low_wPass', 'spin', {'value': 1000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), - ('low_wStop', 'spin', {'value': 2000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), - ('low_gPass', 'spin', {'value': 2.0, 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), - ('low_gStop', 'spin', {'value': 20.0, 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), - ('high_wPass', 'spin', {'value': 3000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), - ('high_wStop', 'spin', {'value': 4000., 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), - ('high_gPass', 'spin', {'value': 2.0, 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), - ('high_gStop', 'spin', {'value': 20.0, 'step': 1, 'dec': True, 'range': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), + ('low_wPass', 'spin', {'value': 1000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('low_wStop', 'spin', {'value': 2000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('low_gPass', 'spin', {'value': 2.0, 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), + ('low_gStop', 'spin', {'value': 20.0, 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), + ('high_wPass', 'spin', {'value': 3000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('high_wStop', 'spin', {'value': 4000., 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'Hz', 'siPrefix': True}), + ('high_gPass', 'spin', {'value': 2.0, 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), + ('high_gStop', 'spin', {'value': 20.0, 'step': 1, 'dec': True, 'bounds': [0.0, None], 'suffix': 'dB', 'siPrefix': True}), ('bidir', 'check', {'checked': True}) ] @@ -160,19 +160,13 @@ class Gaussian(CtrlNode): @metaArrayWrapper def processData(self, data): + sigma = self.ctrls['sigma'].value() try: import scipy.ndimage + return scipy.ndimage.gaussian_filter(data, sigma) except ImportError: - raise Exception("GaussianFilter node requires the package scipy.ndimage.") + return pgfn.gaussianFilter(data, sigma) - if hasattr(data, 'implements') and data.implements('MetaArray'): - info = data.infoCopy() - filt = pgfn.gaussianFilter(data.asarray(), self.ctrls['sigma'].value()) - if 'values' in info[0]: - info[0]['values'] = info[0]['values'][:filt.shape[0]] - return metaarray.MetaArray(filt, info=info) - else: - return pgfn.gaussianFilter(data, self.ctrls['sigma'].value()) class Derivative(CtrlNode): """Returns the pointwise derivative of the input""" diff --git a/pyqtgraph/flowchart/library/Operators.py b/pyqtgraph/flowchart/library/Operators.py index 579d2cd2b7..d1483c1637 100644 --- a/pyqtgraph/flowchart/library/Operators.py +++ b/pyqtgraph/flowchart/library/Operators.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- from ..Node import Node +from .common import CtrlNode + class UniOpNode(Node): """Generic node for performing any operation like Out = In.fn()""" @@ -13,11 +15,22 @@ def __init__(self, name, fn): def process(self, **args): return {'Out': getattr(args['In'], self.fn)()} -class BinOpNode(Node): +class BinOpNode(CtrlNode): """Generic node for performing any operation like A.fn(B)""" + + _dtypes = [ + 'float64', 'float32', 'float16', + 'int64', 'int32', 'int16', 'int8', + 'uint64', 'uint32', 'uint16', 'uint8' + ] + + uiTemplate = [ + ('outputType', 'combo', {'values': ['no change', 'input A', 'input B'] + _dtypes , 'index': 0}) + ] + def __init__(self, name, fn): self.fn = fn - Node.__init__(self, name, terminals={ + CtrlNode.__init__(self, name, terminals={ 'A': {'io': 'in'}, 'B': {'io': 'in'}, 'Out': {'io': 'out', 'bypass': 'A'} @@ -36,6 +49,18 @@ def process(self, **args): out = fn(args['B']) if out is NotImplemented: raise Exception("Operation %s not implemented between %s and %s" % (fn, str(type(args['A'])), str(type(args['B'])))) + + # Coerce dtype if requested + typ = self.stateGroup.state()['outputType'] + if typ == 'no change': + pass + elif typ == 'input A': + out = out.astype(args['A'].dtype) + elif typ == 'input B': + out = out.astype(args['B'].dtype) + else: + out = out.astype(typ) + #print " ", fn, out return {'Out': out} @@ -71,4 +96,10 @@ def __init__(self, name): # try truediv first, followed by div BinOpNode.__init__(self, name, ('__truediv__', '__div__')) +class FloorDivideNode(BinOpNode): + """Returns A // B. Does not check input types.""" + nodeName = 'FloorDivide' + def __init__(self, name): + BinOpNode.__init__(self, name, '__floordiv__') + diff --git a/pyqtgraph/flowchart/library/common.py b/pyqtgraph/flowchart/library/common.py index 425fe86c9f..1f5613c964 100644 --- a/pyqtgraph/flowchart/library/common.py +++ b/pyqtgraph/flowchart/library/common.py @@ -30,6 +30,11 @@ def generateUi(opts): k, t, o = opt else: raise Exception("Widget specification must be (name, type) or (name, type, {opts})") + + ## clean out these options so they don't get sent to SpinBox + hidden = o.pop('hidden', False) + tip = o.pop('tip', None) + if t == 'intSpin': w = QtGui.QSpinBox() if 'max' in o: @@ -63,11 +68,12 @@ def generateUi(opts): w = ColorButton() else: raise Exception("Unknown widget type '%s'" % str(t)) - if 'tip' in o: - w.setToolTip(o['tip']) + + if tip is not None: + w.setToolTip(tip) w.setObjectName(k) l.addRow(k, w) - if o.get('hidden', False): + if hidden: w.hide() label = l.labelForField(w) label.hide() @@ -85,14 +91,15 @@ class CtrlNode(Node): sigStateChanged = QtCore.Signal(object) def __init__(self, name, ui=None, terminals=None): + if terminals is None: + terminals = {'In': {'io': 'in'}, 'Out': {'io': 'out', 'bypass': 'In'}} + Node.__init__(self, name=name, terminals=terminals) + if ui is None: if hasattr(self, 'uiTemplate'): ui = self.uiTemplate else: ui = [] - if terminals is None: - terminals = {'In': {'io': 'in'}, 'Out': {'io': 'out', 'bypass': 'In'}} - Node.__init__(self, name=name, terminals=terminals) self.ui, self.stateGroup, self.ctrls = generateUi(ui) self.stateGroup.sigChanged.connect(self.changed) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 5c107052d1..193cce6a05 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -11,11 +11,13 @@ import decimal, re import ctypes import sys, struct +from .pgcollections import OrderedDict from .python2_3 import asUnicode, basestring -from .Qt import QtGui, QtCore, USE_PYSIDE +from .Qt import QtGui, QtCore, QT_LIB from . import getConfigOption, setConfigOptions -from . import debug - +from . import debug, reload +from .reload import getPreviousVersion +from .metaarray import MetaArray Colors = { @@ -34,10 +36,13 @@ SI_PREFIXES = asUnicode('yzafpnµm kMGTPEZY') SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY' +SI_PREFIX_EXPONENTS = dict([(SI_PREFIXES[i], (i-8)*3) for i in range(len(SI_PREFIXES))]) +SI_PREFIX_EXPONENTS['u'] = -6 +FLOAT_REGEX = re.compile(r'(?P[+-]?((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)\s*((?P[u' + SI_PREFIXES + r']?)(?P\w.*))?$') +INT_REGEX = re.compile(r'(?P[+-]?\d+)\s*(?P[u' + SI_PREFIXES + r']?)(?P.*)$') - - + def siScale(x, minVal=1e-25, allowUnicode=True): """ Return the recommended scale factor and SI prefix string for x. @@ -74,7 +79,8 @@ def siScale(x, minVal=1e-25, allowUnicode=True): pref = SI_PREFIXES_ASCII[m+8] p = .001**m - return (p, pref) + return (p, pref) + def siFormat(x, precision=3, suffix='', space=True, error=None, minVal=1e-25, allowUnicode=True): """ @@ -104,31 +110,65 @@ def siFormat(x, precision=3, suffix='', space=True, error=None, minVal=1e-25, al plusminus = " +/- " fmt = "%." + str(precision) + "g%s%s%s%s" return fmt % (x*p, pref, suffix, plusminus, siFormat(error, precision=precision, suffix=suffix, space=space, minVal=minVal)) + + +def siParse(s, regex=FLOAT_REGEX, suffix=None): + """Convert a value written in SI notation to a tuple (number, si_prefix, suffix). + + Example:: -def siEval(s): + siParse('100 μV") # returns ('100', 'μ', 'V') """ - Convert a value written in SI notation to its equivalent prefixless value + s = asUnicode(s) + s = s.strip() + if suffix is not None and len(suffix) > 0: + if s[-len(suffix):] != suffix: + raise ValueError("String '%s' does not have the expected suffix '%s'" % (s, suffix)) + s = s[:-len(suffix)] + 'X' # add a fake suffix so the regex still picks up the si prefix + + m = regex.match(s) + if m is None: + raise ValueError('Cannot parse number "%s"' % s) + try: + sip = m.group('siPrefix') + except IndexError: + sip = '' + if suffix is None: + try: + suf = m.group('suffix') + except IndexError: + suf = '' + else: + suf = suffix + + return m.group('number'), '' if sip is None else sip, '' if suf is None else suf + + +def siEval(s, typ=float, regex=FLOAT_REGEX, suffix=None): + """ + Convert a value written in SI notation to its equivalent prefixless value. + Example:: siEval("100 μV") # returns 0.0001 """ + val, siprefix, suffix = siParse(s, regex, suffix=suffix) + v = typ(val) + return siApply(v, siprefix) + - s = asUnicode(s) - m = re.match(r'(-?((\d+(\.\d*)?)|(\.\d+))([eE]-?\d+)?)\s*([u' + SI_PREFIXES + r']?).*$', s) - if m is None: - raise Exception("Can't convert string '%s' to number." % s) - v = float(m.groups()[0]) - p = m.groups()[6] - #if p not in SI_PREFIXES: - #raise Exception("Can't convert string '%s' to number--unknown prefix." % s) - if p == '': - n = 0 - elif p == 'u': - n = -2 +def siApply(val, siprefix): + """ + """ + n = SI_PREFIX_EXPONENTS[siprefix] if siprefix != '' else 0 + if n > 0: + return val * 10**n + elif n < 0: + # this case makes it possible to use Decimal objects here + return val / 10**-n else: - n = SI_PREFIXES.index(p) - 8 - return v * 1000**n + return val class Color(QtGui.QColor): @@ -171,7 +211,7 @@ def mkColor(*args): try: return Colors[c] except KeyError: - raise Exception('No color named "%s"' % c) + raise ValueError('No color named "%s"' % c) if len(c) == 3: r = int(c[0]*2, 16) g = int(c[1]*2, 16) @@ -206,18 +246,18 @@ def mkColor(*args): elif len(args[0]) == 2: return intColor(*args[0]) else: - raise Exception(err) + raise TypeError(err) elif type(args[0]) == int: return intColor(args[0]) else: - raise Exception(err) + raise TypeError(err) elif len(args) == 3: (r, g, b) = args a = 255 elif len(args) == 4: (r, g, b, a) = args else: - raise Exception(err) + raise TypeError(err) args = [r,g,b,a] args = [0 if np.isnan(a) or np.isinf(a) else a for a in args] @@ -313,7 +353,7 @@ def colorStr(c): return ('%02x'*4) % colorTuple(c) -def intColor(index, hues=9, values=1, maxValue=255, minValue=150, maxHue=360, minHue=0, sat=255, alpha=255, **kargs): +def intColor(index, hues=9, values=1, maxValue=255, minValue=150, maxHue=360, minHue=0, sat=255, alpha=255): """ Creates a QColor from a single index. Useful for stepping through a predefined list of colors. @@ -325,7 +365,7 @@ def intColor(index, hues=9, values=1, maxValue=255, minValue=150, maxHue=360, mi values = int(values) ind = int(index) % (hues * values) indh = ind % hues - indv = ind / hues + indv = ind // hues if values > 1: v = minValue + indv * ((maxValue-minValue) / (values-1)) else: @@ -375,22 +415,77 @@ def makeArrowPath(headLen=20, tipAngle=20, tailLen=20, tailWidth=3, baseAngle=0) def eq(a, b): - """The great missing equivalence function: Guaranteed evaluation to a single bool value.""" + """The great missing equivalence function: Guaranteed evaluation to a single bool value. + + This function has some important differences from the == operator: + + 1. Returns True if a IS b, even if a==b still evaluates to False, such as with nan values. + 2. Tests for equivalence using ==, but silently ignores some common exceptions that can occur + (AtrtibuteError, ValueError). + 3. When comparing arrays, returns False if the array shapes are not the same. + 4. When comparing arrays of the same shape, returns True only if all elements are equal (whereas + the == operator would return a boolean array). + 5. Collections (dict, list, etc.) must have the same type to be considered equal. One + consequence is that comparing a dict to an OrderedDict will always return False. + """ if a is b: return True - - try: - with warnings.catch_warnings(module=np): # ignore numpy futurewarning (numpy v. 1.10) - e = a==b - except ValueError: + + # Avoid comparing large arrays against scalars; this is expensive and we know it should return False. + aIsArr = isinstance(a, (np.ndarray, MetaArray)) + bIsArr = isinstance(b, (np.ndarray, MetaArray)) + if (aIsArr or bIsArr) and type(a) != type(b): return False - except AttributeError: + + # If both inputs are arrays, we can speeed up comparison if shapes / dtypes don't match + # NOTE: arrays of dissimilar type should be considered unequal even if they are numerically + # equal because they may behave differently when computed on. + if aIsArr and bIsArr and (a.shape != b.shape or a.dtype != b.dtype): + return False + + # Recursively handle common containers + if isinstance(a, dict) and isinstance(b, dict): + if type(a) != type(b) or len(a) != len(b): + return False + if set(a.keys()) != set(b.keys()): + return False + for k, v in a.items(): + if not eq(v, b[k]): + return False + if isinstance(a, OrderedDict) or sys.version_info >= (3, 7): + for a_item, b_item in zip(a.items(), b.items()): + if not eq(a_item, b_item): + return False + return True + if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + if type(a) != type(b) or len(a) != len(b): + return False + for v1,v2 in zip(a, b): + if not eq(v1, v2): + return False + return True + + # Test for equivalence. + # If the test raises a recognized exception, then return Falase + try: + try: + # Sometimes running catch_warnings(module=np) generates AttributeError ??? + catcher = warnings.catch_warnings(module=np) # ignore numpy futurewarning (numpy v. 1.10) + catcher.__enter__() + except Exception: + catcher = None + e = a==b + except (ValueError, AttributeError): return False except: print('failed to evaluate equivalence for:') print(" a:", str(type(a)), str(a)) print(" b:", str(type(b)), str(b)) raise + finally: + if catcher is not None: + catcher.__exit__(None, None, None) + t = type(e) if t is bool: return e @@ -409,12 +504,45 @@ def eq(a, b): else: raise Exception("== operator returned type %s" % str(type(e))) + +def affineSliceCoords(shape, origin, vectors, axes): + """Return the array of coordinates used to sample data arrays in affineSlice(). + """ + # sanity check + if len(shape) != len(vectors): + raise Exception("shape and vectors must have same length.") + if len(origin) != len(axes): + raise Exception("origin and axes must have same length.") + for v in vectors: + if len(v) != len(axes): + raise Exception("each vector must be same length as axes.") + + shape = list(map(np.ceil, shape)) + + ## make sure vectors are arrays + if not isinstance(vectors, np.ndarray): + vectors = np.array(vectors) + if not isinstance(origin, np.ndarray): + origin = np.array(origin) + origin.shape = (len(axes),) + (1,)*len(shape) + + ## Build array of sample locations. + grid = np.mgrid[tuple([slice(0,x) for x in shape])] ## mesh grid of indexes + x = (grid[np.newaxis,...] * vectors.transpose()[(Ellipsis,) + (np.newaxis,)*len(shape)]).sum(axis=1) ## magic + x += origin + + return x + def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, **kargs): """ - Take a slice of any orientation through an array. This is useful for extracting sections of multi-dimensional arrays such as MRI images for viewing as 1D or 2D data. + Take a slice of any orientation through an array. This is useful for extracting sections of multi-dimensional arrays + such as MRI images for viewing as 1D or 2D data. - The slicing axes are aribtrary; they do not need to be orthogonal to the original data or even to each other. It is possible to use this function to extract arbitrary linear, rectangular, or parallelepiped shapes from within larger datasets. The original data is interpolated onto a new array of coordinates using scipy.ndimage.map_coordinates if it is available (see the scipy documentation for more information about this). If scipy is not available, then a slower implementation of map_coordinates is used. + The slicing axes are aribtrary; they do not need to be orthogonal to the original data or even to each other. It is + possible to use this function to extract arbitrary linear, rectangular, or parallelepiped shapes from within larger + datasets. The original data is interpolated onto a new array of coordinates using either interpolateArray if order<2 + or scipy.ndimage.map_coordinates otherwise. For a graphical interface to this function, see :func:`ROI.getArrayRegion ` @@ -453,47 +581,24 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, affineSlice(data, shape=(20,20), origin=(40,0,0), vectors=((-1, 1, 0), (-1, 0, 1)), axes=(1,2,3)) """ - try: - import scipy.ndimage - have_scipy = True - except ImportError: - have_scipy = False - have_scipy = False - - # sanity check - if len(shape) != len(vectors): - raise Exception("shape and vectors must have same length.") - if len(origin) != len(axes): - raise Exception("origin and axes must have same length.") - for v in vectors: - if len(v) != len(axes): - raise Exception("each vector must be same length as axes.") - - shape = list(map(np.ceil, shape)) + x = affineSliceCoords(shape, origin, vectors, axes) ## transpose data so slice axes come first trAx = list(range(data.ndim)) - for x in axes: - trAx.remove(x) + for ax in axes: + trAx.remove(ax) tr1 = tuple(axes) + tuple(trAx) data = data.transpose(tr1) #print "tr1:", tr1 ## dims are now [(slice axes), (other axes)] - - ## make sure vectors are arrays - if not isinstance(vectors, np.ndarray): - vectors = np.array(vectors) - if not isinstance(origin, np.ndarray): - origin = np.array(origin) - origin.shape = (len(axes),) + (1,)*len(shape) - - ## Build array of sample locations. - grid = np.mgrid[tuple([slice(0,x) for x in shape])] ## mesh grid of indexes - x = (grid[np.newaxis,...] * vectors.transpose()[(Ellipsis,) + (np.newaxis,)*len(shape)]).sum(axis=1) ## magic - x += origin - ## iterate manually over unused axes since map_coordinates won't do it for us - if have_scipy: + if order > 1: + try: + import scipy.ndimage + except ImportError: + raise ImportError("Interpolating with order > 1 requires the scipy.ndimage module, but it could not be imported.") + + # iterate manually over unused axes since map_coordinates won't do it for us extraShape = data.shape[len(axes):] output = np.empty(tuple(shape) + extraShape, dtype=data.dtype) for inds in np.ndindex(*extraShape): @@ -502,8 +607,8 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, else: # map_coordinates expects the indexes as the first axis, whereas # interpolateArray expects indexes at the last axis. - tr = tuple(range(1,x.ndim)) + (0,) - output = interpolateArray(data, x.transpose(tr)) + tr = tuple(range(1, x.ndim)) + (0,) + output = interpolateArray(data, x.transpose(tr), order=order) tr = list(range(output.ndim)) trb = [] @@ -520,16 +625,24 @@ def affineSlice(data, shape, origin, vectors, axes, order=1, returnCoords=False, else: return output -def interpolateArray(data, x, default=0.0): + +def interpolateArray(data, x, default=0.0, order=1): """ N-dimensional interpolation similar to scipy.ndimage.map_coordinates. This function returns linearly-interpolated values sampled from a regular - grid of data. + grid of data. It differs from `ndimage.map_coordinates` by allowing broadcasting + within the input array. - *data* is an array of any shape containing the values to be interpolated. - *x* is an array with (shape[-1] <= data.ndim) containing the locations - within *data* to interpolate. + ============== =========================================================================================== + **Arguments:** + *data* Array of any shape containing the values to be interpolated. + *x* Array with (shape[-1] <= data.ndim) containing the locations within *data* to interpolate. + (note: the axes for this argument are transposed relative to the same argument for + `ndimage.map_coordinates`). + *default* Value to return for locations in *x* that are outside the bounds of *data*. + *order* Order of interpolation: 0=nearest, 1=linear. + ============== =========================================================================================== Returns array of shape (x.shape[:-1] + data.shape[x.shape[-1]:]) @@ -574,53 +687,66 @@ def interpolateArray(data, x, default=0.0): This is useful for interpolating from arrays of colors, vertexes, etc. """ + if order not in (0, 1): + raise ValueError("interpolateArray requires order=0 or 1 (got %s)" % order) + prof = debug.Profiler() - + nd = data.ndim md = x.shape[-1] if md > nd: raise TypeError("x.shape[-1] must be less than or equal to data.ndim") - # First we generate arrays of indexes that are needed to - # extract the data surrounding each point - fields = np.mgrid[(slice(0,2),) * md] - xmin = np.floor(x).astype(int) - xmax = xmin + 1 - indexes = np.concatenate([xmin[np.newaxis, ...], xmax[np.newaxis, ...]]) - fieldInds = [] totalMask = np.ones(x.shape[:-1], dtype=bool) # keep track of out-of-bound indexes - for ax in range(md): - mask = (xmin[...,ax] >= 0) & (x[...,ax] <= data.shape[ax]-1) - # keep track of points that need to be set to default - totalMask &= mask + if order == 0: + xinds = np.round(x).astype(int) # NOTE: for 0.5 this rounds to the nearest *even* number + for ax in range(md): + mask = (xinds[...,ax] >= 0) & (xinds[...,ax] <= data.shape[ax]-1) + xinds[...,ax][~mask] = 0 + # keep track of points that need to be set to default + totalMask &= mask + result = data[tuple([xinds[...,i] for i in range(xinds.shape[-1])])] - # ..and keep track of indexes that are out of bounds - # (note that when x[...,ax] == data.shape[ax], then xmax[...,ax] will be out - # of bounds, but the interpolation will work anyway) - mask &= (xmax[...,ax] < data.shape[ax]) - axisIndex = indexes[...,ax][fields[ax]] - axisIndex[axisIndex < 0] = 0 - axisIndex[axisIndex >= data.shape[ax]] = 0 - fieldInds.append(axisIndex) - prof() - - # Get data values surrounding each requested point - fieldData = data[tuple(fieldInds)] - prof() - - ## Interpolate - s = np.empty((md,) + fieldData.shape, dtype=float) - dx = x - xmin - # reshape fields for arithmetic against dx - for ax in range(md): - f1 = fields[ax].reshape(fields[ax].shape + (1,)*(dx.ndim-1)) - sax = f1 * dx[...,ax] + (1-f1) * (1-dx[...,ax]) - sax = sax.reshape(sax.shape + (1,) * (s.ndim-1-sax.ndim)) - s[ax] = sax - s = np.product(s, axis=0) - result = fieldData * s - for i in range(md): - result = result.sum(axis=0) + elif order == 1: + # First we generate arrays of indexes that are needed to + # extract the data surrounding each point + fields = np.mgrid[(slice(0,order+1),) * md] + xmin = np.floor(x).astype(int) + xmax = xmin + 1 + indexes = np.concatenate([xmin[np.newaxis, ...], xmax[np.newaxis, ...]]) + fieldInds = [] + for ax in range(md): + mask = (xmin[...,ax] >= 0) & (x[...,ax] <= data.shape[ax]-1) + # keep track of points that need to be set to default + totalMask &= mask + + # ..and keep track of indexes that are out of bounds + # (note that when x[...,ax] == data.shape[ax], then xmax[...,ax] will be out + # of bounds, but the interpolation will work anyway) + mask &= (xmax[...,ax] < data.shape[ax]) + axisIndex = indexes[...,ax][fields[ax]] + axisIndex[axisIndex < 0] = 0 + axisIndex[axisIndex >= data.shape[ax]] = 0 + fieldInds.append(axisIndex) + prof() + + # Get data values surrounding each requested point + fieldData = data[tuple(fieldInds)] + prof() + + ## Interpolate + s = np.empty((md,) + fieldData.shape, dtype=float) + dx = x - xmin + # reshape fields for arithmetic against dx + for ax in range(md): + f1 = fields[ax].reshape(fields[ax].shape + (1,)*(dx.ndim-1)) + sax = f1 * dx[...,ax] + (1-f1) * (1-dx[...,ax]) + sax = sax.reshape(sax.shape + (1,) * (s.ndim-1-sax.ndim)) + s[ax] = sax + s = np.product(s, axis=0) + result = fieldData * s + for i in range(md): + result = result.sum(axis=0) prof() @@ -656,26 +782,17 @@ def subArray(data, offset, shape, stride): the input in the example above to have shape (10, 7) would cause the output to have shape (2, 3, 7). """ - #data = data.flatten() - data = data[offset:] + data = np.ascontiguousarray(data)[offset:] shape = tuple(shape) - stride = tuple(stride) extraShape = data.shape[1:] - #print data.shape, offset, shape, stride - for i in range(len(shape)): - mask = (slice(None),) * i + (slice(None, shape[i] * stride[i]),) - newShape = shape[:i+1] - if i < len(shape)-1: - newShape += (stride[i],) - newShape += extraShape - #print i, mask, newShape - #print "start:\n", data.shape, data - data = data[mask] - #print "mask:\n", data.shape, data - data = data.reshape(newShape) - #print "reshape:\n", data.shape, data + + strides = list(data.strides[::-1]) + itemsize = strides[-1] + for s in stride[1::-1]: + strides.append(itemsize * s) + strides = tuple(strides[::-1]) - return data + return np.ndarray(buffer=data, shape=shape+extraShape, strides=strides, dtype=data.dtype) def transformToArray(tr): @@ -817,10 +934,12 @@ def solveBilinearTransform(points1, points2): return matrix def rescaleData(data, scale, offset, dtype=None, clip=None): - """Return data rescaled and optionally cast to a new dtype:: - + """Return data rescaled and optionally cast to a new dtype. + + The scaling operation is:: + data => (data-offset) * scale - + """ if dtype is None: dtype = data.dtype @@ -943,7 +1062,6 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): ============== ================================================================================== """ profile = debug.Profiler() - if data.ndim not in (2, 3): raise TypeError("data must be 2D or 3D") if data.ndim == 3 and data.shape[2] > 4: @@ -965,6 +1083,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): raise Exception('levels argument is required for float input types') if not isinstance(levels, np.ndarray): levels = np.array(levels) + levels = levels.astype(np.float) if levels.ndim == 1: if levels.shape[0] != 2: raise Exception('levels argument must have length 2') @@ -981,7 +1100,7 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): # Decide on maximum scaled value if scale is None: if lut is not None: - scale = lut.shape[0] - 1 + scale = lut.shape[0] else: scale = 255. @@ -990,7 +1109,14 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): dtype = np.ubyte else: dtype = np.min_scalar_type(lut.shape[0]-1) - + + # awkward, but fastest numpy native nan evaluation + # + nanMask = None + if data.dtype.kind == 'f' and np.isnan(data.min()): + nanMask = np.isnan(data) + if data.ndim > 2: + nanMask = np.any(nanMask, axis=-1) # Apply levels if given if levels is not None: if isinstance(levels, np.ndarray) and levels.ndim == 2: @@ -1001,20 +1127,22 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): for i in range(data.shape[-1]): minVal, maxVal = levels[i] if minVal == maxVal: - maxVal += 1e-16 - newData[...,i] = rescaleData(data[...,i], scale/(maxVal-minVal), minVal, dtype=dtype) + maxVal = np.nextafter(maxVal, 2*maxVal) + rng = maxVal-minVal + rng = 1 if rng == 0 else rng + newData[...,i] = rescaleData(data[...,i], scale / rng, minVal, dtype=dtype) data = newData else: # Apply level scaling unless it would have no effect on the data minVal, maxVal = levels if minVal != 0 or maxVal != scale: if minVal == maxVal: - maxVal += 1e-16 - data = rescaleData(data, scale/(maxVal-minVal), minVal, dtype=dtype) - + maxVal = np.nextafter(maxVal, 2*maxVal) + rng = maxVal-minVal + rng = 1 if rng == 0 else rng + data = rescaleData(data, scale/rng, minVal, dtype=dtype) profile() - # apply LUT if given if lut is not None: data = applyLookupTable(data, lut) @@ -1057,7 +1185,12 @@ def makeARGB(data, lut=None, levels=None, scale=None, useRGBA=False): imgData[..., 3] = 255 else: alpha = True - + + # apply nan mask through alpha channel + if nanMask is not None: + alpha = True + imgData[nanMask, 3] = 0 + profile() return imgData, alpha @@ -1126,19 +1259,12 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): if copy is True and copied is False: imgData = imgData.copy() - if USE_PYSIDE: + if QT_LIB in ['PySide', 'PySide2']: ch = ctypes.c_char.from_buffer(imgData, 0) img = QtGui.QImage(ch, imgData.shape[1], imgData.shape[0], imgFormat) else: - #addr = ctypes.addressof(ctypes.c_char.from_buffer(imgData, 0)) ## PyQt API for QImage changed between 4.9.3 and 4.9.6 (I don't know exactly which version it was) ## So we first attempt the 4.9.6 API, then fall back to 4.9.3 - #addr = ctypes.c_char.from_buffer(imgData, 0) - #try: - #img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) - #except TypeError: - #addr = ctypes.addressof(addr) - #img = QtGui.QImage(addr, imgData.shape[1], imgData.shape[0], imgFormat) try: img = QtGui.QImage(imgData.ctypes.data, imgData.shape[1], imgData.shape[0], imgFormat) except: @@ -1151,16 +1277,6 @@ def makeQImage(imgData, alpha=None, copy=True, transpose=True): img.data = imgData return img - #try: - #buf = imgData.data - #except AttributeError: ## happens when image data is non-contiguous - #buf = imgData.data - - #profiler() - #qimage = QtGui.QImage(buf, imgData.shape[1], imgData.shape[0], imgFormat) - #profiler() - #qimage.data = imgData - #return qimage def imageToArray(img, copy=False, transpose=True): """ @@ -1171,7 +1287,7 @@ def imageToArray(img, copy=False, transpose=True): """ fmt = img.format() ptr = img.bits() - if USE_PYSIDE: + if QT_LIB in ['PySide', 'PySide2']: arr = np.frombuffer(ptr, dtype=np.ubyte) else: ptr.setsize(img.byteCount()) @@ -1273,7 +1389,7 @@ def gaussianFilter(data, sigma): # clip off extra data sl = [slice(None)] * data.ndim sl[ax] = slice(filtered.shape[ax]-data.shape[ax],None,None) - filtered = filtered[sl] + filtered = filtered[tuple(sl)] return filtered + baseline @@ -2066,7 +2182,7 @@ def isosurface(data, level): ## compute lookup table of index: vertexes mapping faceTableI = np.zeros((len(triTable), i*3), dtype=np.ubyte) faceTableInds = np.argwhere(nTableFaces == i) - faceTableI[faceTableInds[:,0]] = np.array([triTable[j] for j in faceTableInds]) + faceTableI[faceTableInds[:,0]] = np.array([triTable[j[0]] for j in faceTableInds]) faceTableI = faceTableI.reshape((len(triTable), i, 3)) faceShiftTables.append(edgeShifts[faceTableI]) @@ -2322,3 +2438,42 @@ def toposort(deps, nodes=None, seen=None, stack=None, depth=0): sorted.extend( toposort(deps, deps[n], seen, stack+[n], depth=depth+1)) sorted.append(n) return sorted + + +def disconnect(signal, slot): + """Disconnect a Qt signal from a slot. + + This method augments Qt's Signal.disconnect(): + + * Return bool indicating whether disconnection was successful, rather than + raising an exception + * Attempt to disconnect prior versions of the slot when using pg.reload + """ + while True: + try: + signal.disconnect(slot) + return True + except (TypeError, RuntimeError): + slot = reload.getPreviousVersion(slot) + if slot is None: + return False + + +class SignalBlock(object): + """Class used to temporarily block a Qt signal connection:: + + with SignalBlock(signal, slot): + # do something that emits a signal; it will + # not be delivered to slot + """ + def __init__(self, signal, slot): + self.signal = signal + self.slot = slot + + def __enter__(self): + self.reconnect = disconnect(self.signal, self.slot) + return self + + def __exit__(self, *args): + if self.reconnect: + self.signal.connect(self.slot) diff --git a/pyqtgraph/graphicsItems/ArrowItem.py b/pyqtgraph/graphicsItems/ArrowItem.py index 77e6195f28..897cbc5025 100644 --- a/pyqtgraph/graphicsItems/ArrowItem.py +++ b/pyqtgraph/graphicsItems/ArrowItem.py @@ -39,7 +39,6 @@ def __init__(self, **opts): self.setStyle(**defaultOpts) - self.rotate(self.opts['angle']) self.moveBy(*self.opts['pos']) def setStyle(self, **opts): @@ -72,7 +71,10 @@ def setStyle(self, **opts): self.opts.update(opts) opt = dict([(k,self.opts[k]) for k in ['headLen', 'tipAngle', 'baseAngle', 'tailLen', 'tailWidth']]) - self.path = fn.makeArrowPath(**opt) + tr = QtGui.QTransform() + tr.rotate(self.opts['angle']) + self.path = tr.map(fn.makeArrowPath(**opt)) + self.setPath(self.path) self.setPen(fn.mkPen(self.opts['pen'])) @@ -82,7 +84,8 @@ def setStyle(self, **opts): self.setFlags(self.flags() | self.ItemIgnoresTransformations) else: self.setFlags(self.flags() & ~self.ItemIgnoresTransformations) - + + def paint(self, p, *args): p.setRenderHint(QtGui.QPainter.Antialiasing) QtGui.QGraphicsPathItem.paint(self, p, *args) diff --git a/pyqtgraph/graphicsItems/AxisItem.py b/pyqtgraph/graphicsItems/AxisItem.py index b125cb7e5b..29f3ad62f1 100644 --- a/pyqtgraph/graphicsItems/AxisItem.py +++ b/pyqtgraph/graphicsItems/AxisItem.py @@ -1,8 +1,10 @@ +# -*- coding: utf-8 -*- from ..Qt import QtGui, QtCore from ..python2_3 import asUnicode import numpy as np from ..Point import Point from .. import debug as debug +import sys import weakref from .. import functions as fn from .. import getConfigOption @@ -14,10 +16,10 @@ class AxisItem(GraphicsWidget): GraphicsItem showing a single plot axis with ticks, values, and label. Can be configured to fit on any side of a plot, and can automatically synchronize its displayed scale with ViewBox items. Ticks can be extended to draw a grid. - If maxTickLength is negative, ticks point into the plot. + If maxTickLength is negative, ticks point into the plot. """ - - def __init__(self, orientation, pen=None, linkView=None, parent=None, maxTickLength=-5, showValues=True): + + def __init__(self, orientation, pen=None, textPen=None, linkView=None, parent=None, maxTickLength=-5, showValues=True, text='', units='', unitPrefix='', **args): """ ============== =============================================================== **Arguments:** @@ -26,11 +28,20 @@ def __init__(self, orientation, pen=None, linkView=None, parent=None, maxTickLen into the plot, positive values draw outward. linkView (ViewBox) causes the range of values displayed in the axis to be linked to the visible range of a ViewBox. - showValues (bool) Whether to display values adjacent to ticks + showValues (bool) Whether to display values adjacent to ticks pen (QPen) Pen used when drawing ticks. + textPen (QPen) Pen used when drawing tick labels. + text The text (excluding units) to display on the label for this + axis. + units The units for this axis. Units should generally be given + without any scaling prefix (eg, 'V' instead of 'mV'). The + scaling prefix will be automatically prepended based on the + range of data displayed. + args All extra keyword arguments become CSS style options for + the tag which will surround the axis label and units. ============== =============================================================== """ - + GraphicsWidget.__init__(self, parent) self.label = QtGui.QGraphicsTextItem(self) self.picture = None @@ -39,15 +50,15 @@ def __init__(self, orientation, pen=None, linkView=None, parent=None, maxTickLen raise Exception("Orientation argument must be one of 'left', 'right', 'top', or 'bottom'.") if orientation in ['left', 'right']: self.label.rotate(-90) - + self.style = { - 'tickTextOffset': [5, 2], ## (horizontal, vertical) spacing between text and axis + 'tickTextOffset': [5, 2], ## (horizontal, vertical) spacing between text and axis 'tickTextWidth': 30, ## space reserved for tick text - 'tickTextHeight': 18, + 'tickTextHeight': 18, 'autoExpandTextSpace': True, ## automatically expand text space if needed 'tickFont': None, - 'stopAxisAtTick': (False, False), ## whether axis is drawn to edge of box or to last tick - 'textFillLimits': [ ## how much of the axis to fill up with tick text, maximally. + 'stopAxisAtTick': (False, False), ## whether axis is drawn to edge of box or to last tick + 'textFillLimits': [ ## how much of the axis to fill up with tick text, maximally. (0, 0.8), ## never fill more than 80% of the axis (2, 0.6), ## If we already have 2 ticks with text, fill no more than 60% of the axis (4, 0.4), ## If we already have 4 ticks with text, fill no more than 40% of the axis @@ -58,93 +69,97 @@ def __init__(self, orientation, pen=None, linkView=None, parent=None, maxTickLen 'maxTickLevel': 2, 'maxTextLevel': 2, } - - self.textWidth = 30 ## Keeps track of maximum width / height of tick text + + self.textWidth = 30 ## Keeps track of maximum width / height of tick text self.textHeight = 18 - + # If the user specifies a width / height, remember that setting # indefinitely. self.fixedWidth = None self.fixedHeight = None - - self.labelText = '' - self.labelUnits = '' - self.labelUnitPrefix='' - self.labelStyle = {} + + self.labelText = text + self.labelUnits = units + self.labelUnitPrefix = unitPrefix + self.labelStyle = args self.logMode = False - self.tickFont = None - + self._tickLevels = None ## used to override the automatic ticking system with explicit ticks self._tickSpacing = None # used to override default tickSpacing method self.scale = 1.0 self.autoSIPrefix = True self.autoSIPrefixScale = 1.0 - + + self.showLabel(False) + self.setRange(0, 1) - + if pen is None: self.setPen() else: self.setPen(pen) - + + if textPen is None: + self.setTextPen() + else: + self.setTextPen(pen) + self._linkedView = None if linkView is not None: self.linkToView(linkView) - - self.showLabel(False) - + self.grid = False #self.setCacheMode(self.DeviceCoordinateCache) def setStyle(self, **kwds): """ Set various style options. - + =================== ======================================================= Keyword Arguments: - tickLength (int) The maximum length of ticks in pixels. - Positive values point toward the text; negative + tickLength (int) The maximum length of ticks in pixels. + Positive values point toward the text; negative values point away. tickTextOffset (int) reserved spacing between text and axis in px tickTextWidth (int) Horizontal space reserved for tick text in px tickTextHeight (int) Vertical space reserved for tick text in px autoExpandTextSpace (bool) Automatically expand text space if the tick strings become too long. - tickFont (QFont or None) Determines the font used for tick + tickFont (QFont or None) Determines the font used for tick values. Use None for the default font. - stopAxisAtTick (tuple: (bool min, bool max)) If True, the axis - line is drawn only as far as the last tick. - Otherwise, the line is drawn to the edge of the + stopAxisAtTick (tuple: (bool min, bool max)) If True, the axis + line is drawn only as far as the last tick. + Otherwise, the line is drawn to the edge of the AxisItem boundary. textFillLimits (list of (tick #, % fill) tuples). This structure - determines how the AxisItem decides how many ticks + determines how the AxisItem decides how many ticks should have text appear next to them. Each tuple in the list specifies what fraction of the axis length may be occupied by text, given the number of ticks that already have text displayed. For example:: - + [(0, 0.8), # Never fill more than 80% of the axis - (2, 0.6), # If we already have 2 ticks with text, + (2, 0.6), # If we already have 2 ticks with text, # fill no more than 60% of the axis - (4, 0.4), # If we already have 4 ticks with text, + (4, 0.4), # If we already have 4 ticks with text, # fill no more than 40% of the axis - (6, 0.2)] # If we already have 6 ticks with text, + (6, 0.2)] # If we already have 6 ticks with text, # fill no more than 20% of the axis - + showValues (bool) indicates whether text is displayed adjacent to ticks. =================== ======================================================= - + Added in version 0.9.9 """ for kwd,value in kwds.items(): if kwd not in self.style: raise NameError("%s is not a valid style argument." % kwd) - + if kwd in ('tickLength', 'tickTextOffset', 'tickTextWidth', 'tickTextHeight'): if not isinstance(value, int): raise ValueError("Argument '%s' must be int" % kwd) - + if kwd == 'tickTextOffset': if self.orientation in ('left', 'right'): self.style['tickTextOffset'][0] = value @@ -158,19 +173,19 @@ def setStyle(self, **kwds): self.style[kwd] = value else: self.style[kwd] = value - + self.picture = None self._adjustSize() self.update() - + def close(self): self.scene().removeItem(self.label) self.label = None self.scene().removeItem(self) - + def setGrid(self, grid): """Set the alpha value (0-255) for the grid, or False to disable. - + When grid lines are enabled, the axis tick lines are extended to cover the extent of the linked ViewBox, if any. """ @@ -178,28 +193,32 @@ def setGrid(self, grid): self.picture = None self.prepareGeometryChange() self.update() - + def setLogMode(self, log): """ If *log* is True, then ticks are displayed on a logarithmic scale and values - are adjusted accordingly. (This is usually accessed by changing the log mode + are adjusted accordingly. (This is usually accessed by changing the log mode of a :func:`PlotItem `) """ self.logMode = log self.picture = None self.update() - + def setTickFont(self, font): - self.tickFont = font + """ + (QFont or None) Determines the font used for tick values. + Use None for the default font. + """ + self.style['tickFont'] = font self.picture = None self.prepareGeometryChange() ## Need to re-allocate space depending on font size? - + self.update() - + def resizeEvent(self, ev=None): #s = self.size() - + ## Set the position of the label nudge = 5 br = self.label.boundingRect() @@ -218,7 +237,7 @@ def resizeEvent(self, ev=None): p.setY(int(self.size().height()-br.height()+nudge)) self.label.setPos(p) self.picture = None - + def showLabel(self, show=True): """Show/hide the label text for this axis.""" #self.drawLabel = show @@ -229,10 +248,10 @@ def showLabel(self, show=True): self._updateHeight() if self.autoSIPrefix: self.updateAutoSIPrefix() - + def setLabel(self, text=None, units=None, unitPrefix=None, **args): """Set the text displayed adjacent to the axis. - + ============== ============================================================= **Arguments:** text The text (excluding units) to display on the label for this @@ -241,26 +260,29 @@ def setLabel(self, text=None, units=None, unitPrefix=None, **args): without any scaling prefix (eg, 'V' instead of 'mV'). The scaling prefix will be automatically prepended based on the range of data displayed. - **args All extra keyword arguments become CSS style options for + args All extra keyword arguments become CSS style options for the tag which will surround the axis label and units. ============== ============================================================= - + The final text generated for the label will look like:: - + {text} (prefix{units}) - - Each extra keyword argument will become a CSS option in the above template. + + Each extra keyword argument will become a CSS option in the above template. For example, you can set the font size and color of the label:: - + labelStyle = {'color': '#FFF', 'font-size': '14pt'} axis.setLabel('label text', units='V', **labelStyle) - + """ + show_label = False if text is not None: self.labelText = text - self.showLabel() + show_label = True if units is not None: self.labelUnits = units + show_label = True + if show_label: self.showLabel() if unitPrefix is not None: self.labelUnitPrefix = unitPrefix @@ -270,7 +292,7 @@ def setLabel(self, text=None, units=None, unitPrefix=None, **args): self._adjustSize() self.picture = None self.update() - + def labelString(self): if self.labelUnits == '': if not self.autoSIPrefix or self.autoSIPrefixScale == 1.0: @@ -280,13 +302,13 @@ def labelString(self): else: #print repr(self.labelUnitPrefix), repr(self.labelUnits) units = asUnicode('(%s%s)') % (asUnicode(self.labelUnitPrefix), asUnicode(self.labelUnits)) - + s = asUnicode('%s %s') % (asUnicode(self.labelText), asUnicode(units)) - + style = ';'.join(['%s: %s' % (k, self.labelStyle[k]) for k in self.labelStyle]) - + return asUnicode("%s") % (style, asUnicode(s)) - + def _updateMaxTextSize(self, x): ## Informs that the maximum tick size orthogonal to the axis has ## changed; we use this to decide whether the item needs to be resized @@ -305,22 +327,22 @@ def _updateMaxTextSize(self, x): if self.style['autoExpandTextSpace'] is True: self._updateHeight() #return True ## size has changed - + def _adjustSize(self): if self.orientation in ['left', 'right']: self._updateWidth() else: self._updateHeight() - + def setHeight(self, h=None): """Set the height of this axis reserved for ticks and tick labels. The height of the axis label is automatically added. - + If *height* is None, then the value will be determined automatically based on the size of the tick text.""" self.fixedHeight = h self._updateHeight() - + def _updateHeight(self): if not self.isVisible(): h = 0 @@ -338,20 +360,20 @@ def _updateHeight(self): h += self.label.boundingRect().height() * 0.8 else: h = self.fixedHeight - + self.setMaximumHeight(h) self.setMinimumHeight(h) self.picture = None - + def setWidth(self, w=None): """Set the width of this axis reserved for ticks and tick labels. The width of the axis label is automatically added. - + If *width* is None, then the value will be determined automatically based on the size of the tick text.""" self.fixedWidth = w self._updateWidth() - + def _updateWidth(self): if not self.isVisible(): w = 0 @@ -369,20 +391,20 @@ def _updateWidth(self): w += self.label.boundingRect().height() * 0.8 ## bounding rect is usually an overestimate else: w = self.fixedWidth - + self.setMaximumWidth(w) self.setMinimumWidth(w) self.picture = None - + def pen(self): if self._pen is None: return fn.mkPen(getConfigOption('foreground')) return fn.mkPen(self._pen) - + def setPen(self, *args, **kwargs): """ Set the pen used for drawing text, axes, ticks, and grid lines. - If no arguments are given, the default foreground color will be used + If no arguments are given, the default foreground color will be used (see :func:`setConfigOption `). """ self.picture = None @@ -393,59 +415,82 @@ def setPen(self, *args, **kwargs): self.labelStyle['color'] = '#' + fn.colorStr(self._pen.color())[:6] self.setLabel() self.update() - + + def textPen(self): + if self._textPen is None: + return fn.mkPen(getConfigOption('foreground')) + return fn.mkPen(self._textPen) + + def setTextPen(self, *args, **kwargs): + """ + Set the pen used for drawing text. + If no arguments are given, the default foreground color will be used. + """ + self.picture = None + if args or kwargs: + self._textPen = fn.mkPen(*args, **kwargs) + else: + self._textPen = fn.mkPen(getConfigOption('foreground')) + self.labelStyle['color'] = '#' + fn.colorStr(self._textPen.color())[:6] + self.setLabel() + self.update() + def setScale(self, scale=None): """ - Set the value scaling for this axis. - + Set the value scaling for this axis. + Setting this value causes the axis to draw ticks and tick labels as if - the view coordinate system were scaled. By default, the axis scaling is + the view coordinate system were scaled. By default, the axis scaling is 1.0. """ # Deprecated usage, kept for backward compatibility - if scale is None: + if scale is None: scale = 1.0 self.enableAutoSIPrefix(True) - + if scale != self.scale: self.scale = scale self.setLabel() self.picture = None self.update() - + def enableAutoSIPrefix(self, enable=True): """ - Enable (or disable) automatic SI prefix scaling on this axis. - - When enabled, this feature automatically determines the best SI prefix + Enable (or disable) automatic SI prefix scaling on this axis. + + When enabled, this feature automatically determines the best SI prefix to prepend to the label units, while ensuring that axis values are scaled - accordingly. - - For example, if the axis spans values from -0.1 to 0.1 and has units set + accordingly. + + For example, if the axis spans values from -0.1 to 0.1 and has units set to 'V' then the axis would display values -100 to 100 and the units would appear as 'mV' - + This feature is enabled by default, and is only available when a suffix (unit string) is provided to display on the label. """ self.autoSIPrefix = enable self.updateAutoSIPrefix() - + def updateAutoSIPrefix(self): if self.label.isVisible(): - (scale, prefix) = fn.siScale(max(abs(self.range[0]*self.scale), abs(self.range[1]*self.scale))) + if self.logMode: + _range = 10**np.array(self.range) + else: + _range = self.range + (scale, prefix) = fn.siScale(max(abs(_range[0]*self.scale), abs(_range[1]*self.scale))) if self.labelUnits == '' and prefix in ['k', 'm']: ## If we are not showing units, wait until 1e6 before scaling. scale = 1.0 prefix = '' + self.autoSIPrefixScale = scale self.setLabel(unitPrefix=prefix) else: - scale = 1.0 - - self.autoSIPrefixScale = scale + self.autoSIPrefixScale = 1.0 + self.picture = None self.update() - - + + def setRange(self, mn, mx): """Set the range of values displayed by the axis. Usually this is handled automatically by linking the axis to a ViewBox with :func:`linkToView `""" @@ -456,31 +501,40 @@ def setRange(self, mn, mx): self.updateAutoSIPrefix() self.picture = None self.update() - + def linkedView(self): """Return the ViewBox this axis is linked to""" if self._linkedView is None: return None else: return self._linkedView() - + def linkToView(self, view): """Link this axis to a ViewBox, causing its displayed range to match the visible range of the view.""" - oldView = self.linkedView() + self.unlinkFromView() + self._linkedView = weakref.ref(view) + if self.orientation in ['right', 'left']: + view.sigYRangeChanged.connect(self.linkedViewChanged) + else: + view.sigXRangeChanged.connect(self.linkedViewChanged) + + view.sigResized.connect(self.linkedViewChanged) + + def unlinkFromView(self): + """Unlink this axis from a ViewBox.""" + oldView = self.linkedView() + self._linkedView = None if self.orientation in ['right', 'left']: if oldView is not None: oldView.sigYRangeChanged.disconnect(self.linkedViewChanged) - view.sigYRangeChanged.connect(self.linkedViewChanged) else: if oldView is not None: oldView.sigXRangeChanged.disconnect(self.linkedViewChanged) - view.sigXRangeChanged.connect(self.linkedViewChanged) - + if oldView is not None: oldView.sigResized.disconnect(self.linkedViewChanged) - view.sigResized.connect(self.linkedViewChanged) - + def linkedViewChanged(self, view, newRange=None): if self.orientation in ['right', 'left']: if newRange is None: @@ -496,7 +550,7 @@ def linkedViewChanged(self, view, newRange=None): self.setRange(*newRange[::-1]) else: self.setRange(*newRange) - + def boundingRect(self): linkedView = self.linkedView() if linkedView is None or self.grid is False: @@ -515,7 +569,7 @@ def boundingRect(self): return rect else: return self.mapRectFromParent(self.geometry()) | linkedView.mapRectToItem(self, linkedView.boundingRect()) - + def paint(self, p, opt, widget): profiler = debug.Profiler() if self.picture is None: @@ -544,26 +598,26 @@ def setTicks(self, ticks): [ (minorTickValue1, minorTickString1), (minorTickValue2, minorTickString2), ... ], ... ] - + If *ticks* is None, then the default tick system will be used instead. """ self._tickLevels = ticks self.picture = None self.update() - + def setTickSpacing(self, major=None, minor=None, levels=None): """ - Explicitly determine the spacing of major and minor ticks. This + Explicitly determine the spacing of major and minor ticks. This overrides the default behavior of the tickSpacing method, and disables - the effect of setTicks(). Arguments may be either *major* and *minor*, - or *levels* which is a list of (spacing, offset) tuples for each + the effect of setTicks(). Arguments may be either *major* and *minor*, + or *levels* which is a list of (spacing, offset) tuples for each tick level desired. - + If no arguments are given, then the default behavior of tickSpacing is enabled. - + Examples:: - + # two levels, all offsets = 0 axis.setTickSpacing(5, 1) # three levels, all offsets = 0 @@ -571,7 +625,7 @@ def setTickSpacing(self, major=None, minor=None, levels=None): # reset to default axis.setTickSpacing() """ - + if levels is None: if major is None: levels = None @@ -580,16 +634,16 @@ def setTickSpacing(self, major=None, minor=None, levels=None): self._tickSpacing = levels self.picture = None self.update() - + def tickSpacing(self, minVal, maxVal, size): """Return values describing the desired spacing and offset of ticks. - - This method is called whenever the axis needs to be redrawn and is a + + This method is called whenever the axis needs to be redrawn and is a good method to override in subclasses that require control over tick locations. - + The return value must be a list of tuples, one for each set of ticks:: - + [ (major tick spacing, offset), (minor tick spacing, offset), @@ -600,41 +654,40 @@ def tickSpacing(self, minVal, maxVal, size): # First check for override tick spacing if self._tickSpacing is not None: return self._tickSpacing - + dif = abs(maxVal - minVal) if dif == 0: return [] - + ## decide optimal minor tick spacing in pixels (this is just aesthetics) optimalTickCount = max(2., np.log(size)) - - ## optimal minor tick spacing + + ## optimal minor tick spacing optimalSpacing = dif / optimalTickCount - + ## the largest power-of-10 spacing which is smaller than optimal p10unit = 10 ** np.floor(np.log10(optimalSpacing)) - + ## Determine major/minor tick spacings which flank the optimal spacing. intervals = np.array([1., 2., 10., 20., 100.]) * p10unit minorIndex = 0 while intervals[minorIndex+1] <= optimalSpacing: minorIndex += 1 - + levels = [ (intervals[minorIndex+2], 0), (intervals[minorIndex+1], 0), #(intervals[minorIndex], 0) ## Pretty, but eats up CPU ] - + if self.style['maxTickLevel'] >= 2: ## decide whether to include the last level of ticks minSpacing = min(size / 20., 30.) maxTickCount = size / minSpacing if dif / intervals[minorIndex] <= maxTickCount: levels.append((intervals[minorIndex], 0)) - return levels - - + + return levels ##### This does not work -- switching between 2/5 confuses the automatic text-level-selection ### Determine major/minor tick spacings which flank the optimal spacing. @@ -642,7 +695,7 @@ def tickSpacing(self, minVal, maxVal, size): #minorIndex = 0 #while intervals[minorIndex+1] <= optimalSpacing: #minorIndex += 1 - + ### make sure we never see 5 and 2 at the same time #intIndexes = [ #[0,1,3], @@ -651,56 +704,56 @@ def tickSpacing(self, minVal, maxVal, size): #[3,4,6], #[3,5,6], #][minorIndex] - + #return [ #(intervals[intIndexes[2]], 0), #(intervals[intIndexes[1]], 0), #(intervals[intIndexes[0]], 0) #] - + def tickValues(self, minVal, maxVal, size): """ Return the values and spacing of ticks to draw:: - - [ - (spacing, [major ticks]), - (spacing, [minor ticks]), - ... + + [ + (spacing, [major ticks]), + (spacing, [minor ticks]), + ... ] - + By default, this method calls tickSpacing to determine the correct tick locations. This is a good method to override in subclasses. """ minVal, maxVal = sorted((minVal, maxVal)) - - minVal *= self.scale + + minVal *= self.scale maxVal *= self.scale #size *= self.scale - + ticks = [] tickLevels = self.tickSpacing(minVal, maxVal, size) allValues = np.array([]) for i in range(len(tickLevels)): spacing, offset = tickLevels[i] - + ## determine starting tick start = (np.ceil((minVal-offset) / spacing) * spacing) + offset - + ## determine number of ticks num = int((maxVal-start) / spacing) + 1 values = (np.arange(num) * spacing + start) / self.scale ## remove any ticks that were present in higher levels ## we assume here that if the difference between a tick value and a previously seen tick value ## is less than spacing/100, then they are 'equal' and we can ignore the new tick. - values = list(filter(lambda x: all(np.abs(allValues-x) > spacing*0.01), values) ) + values = list(filter(lambda x: all(np.abs(allValues-x) > spacing/self.scale*0.01), values)) allValues = np.concatenate([allValues, values]) ticks.append((spacing/self.scale, values)) - + if self.logMode: return self.logTickValues(minVal, maxVal, size, ticks) - - + + #nticks = [] #for t in ticks: #nvals = [] @@ -708,24 +761,24 @@ def tickValues(self, minVal, maxVal, size): #nvals.append(v/self.scale) #nticks.append((t[0]/self.scale,nvals)) #ticks = nticks - + return ticks - + def logTickValues(self, minVal, maxVal, size, stdTicks): - + ## start with the tick spacing given by tickValues(). ## Any level whose spacing is < 1 needs to be converted to log scale - + ticks = [] for (spacing, t) in stdTicks: if spacing >= 1.0: ticks.append((spacing, t)) - + if len(ticks) < 3: v1 = int(np.floor(minVal)) v2 = int(np.ceil(maxVal)) #major = list(range(v1+1, v2)) - + minor = [] for v in range(v1, v2): minor.extend(v + np.log10(np.arange(1, 10))) @@ -734,21 +787,21 @@ def logTickValues(self, minVal, maxVal, size, stdTicks): return ticks def tickStrings(self, values, scale, spacing): - """Return the strings that should be placed next to ticks. This method is called + """Return the strings that should be placed next to ticks. This method is called when redrawing the axis and is a good method to override in subclasses. - The method is called with a list of tick values, a scaling factor (see below), and the - spacing between ticks (this is required since, in some instances, there may be only + The method is called with a list of tick values, a scaling factor (see below), and the + spacing between ticks (this is required since, in some instances, there may be only one tick and thus no other way to determine the tick spacing) - + The scale argument is used when the axis label is displaying units which may have an SI scaling prefix. When determining the text to display, use value*scale to correctly account for this prefix. For example, if the axis label's units are set to 'V', then a tick value of 0.001 might - be accompanied by a scale value of 1000. This indicates that the label is displaying 'mV', and + be accompanied by a scale value of 1000. This indicates that the label is displaying 'mV', and thus the tick should display 0.001 * 1000 = 1. """ if self.logMode: return self.logTickStrings(values, scale, spacing) - + places = max(0, np.ceil(-np.log10(spacing*scale))) strings = [] for v in values: @@ -759,27 +812,57 @@ def tickStrings(self, values, scale, spacing): vstr = ("%%0.%df" % places) % vs strings.append(vstr) return strings - + def logTickStrings(self, values, scale, spacing): - return ["%0.1g"%x for x in 10 ** np.array(values).astype(float)] - + estrings = ["%0.1g"%x for x in 10 ** np.array(values).astype(float) * np.array(scale)] + + if sys.version_info < (3, 0): + # python 2 does not support unicode strings like that + return estrings + else: # python 3+ + convdict = {"0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + } + dstrings = [] + for e in estrings: + if e.count("e"): + v, p = e.split("e") + sign = "⁻" if p[0] == "-" else "" + pot = "".join([convdict[pp] for pp in p[1:].lstrip("0")]) + if v == "1": + v = "" + else: + v = v + "·" + dstrings.append(v + "10" + sign + pot) + else: + dstrings.append(e) + return dstrings + def generateDrawSpecs(self, p): """ Calls tickValues() and tickStrings() to determine where and how ticks should - be drawn, then generates from this a set of drawing commands to be + be drawn, then generates from this a set of drawing commands to be interpreted by drawPicture(). """ profiler = debug.Profiler() #bounds = self.boundingRect() bounds = self.mapRectFromParent(self.geometry()) - + linkedView = self.linkedView() if linkedView is None or self.grid is False: tickBounds = bounds else: tickBounds = linkedView.mapRectToItem(self, linkedView.boundingRect()) - + if self.orientation == 'left': span = (bounds.topRight(), bounds.bottomRight()) tickStart = tickBounds.right() @@ -805,7 +888,7 @@ def generateDrawSpecs(self, p): tickDir = 1 axis = 1 #print tickStart, tickStop, span - + ## determine size of this item in pixels points = list(map(self.mapToDevice, span)) if None in points: @@ -830,7 +913,7 @@ def generateDrawSpecs(self, p): for val, strn in level: values.append(val) strings.append(strn) - + ## determine mapping between tick values and local coordinates dif = self.range[1] - self.range[0] if dif == 0: @@ -843,29 +926,29 @@ def generateDrawSpecs(self, p): else: xScale = bounds.width() / dif offset = self.range[0] * xScale - + xRange = [x * xScale - offset for x in self.range] xMin = min(xRange) xMax = max(xRange) - + profiler('init') - + tickPositions = [] # remembers positions of previously drawn ticks - + ## compute coordinates to draw ticks ## draw three different intervals, long ticks first tickSpecs = [] for i in range(len(tickLevels)): tickPositions.append([]) ticks = tickLevels[i][1] - + ## length of tick tickLength = self.style['tickLength'] / ((i*0.5)+1.0) - + lineAlpha = 255 / (i+1) if self.grid is not False: lineAlpha *= self.grid/255. * np.clip((0.05 * lengthInPixels / (len(ticks)+1)), 0., 1.) - + for v in ticks: ## determine actual position to draw this tick x = (v * xScale) - offset @@ -873,7 +956,7 @@ def generateDrawSpecs(self, p): tickPositions[i].append(None) continue tickPositions[i].append(x) - + p1 = [x, x] p2 = [x, x] p1[axis] = tickStart @@ -882,27 +965,31 @@ def generateDrawSpecs(self, p): p2[axis] += tickLength*tickDir tickPen = self.pen() color = tickPen.color() - color.setAlpha(lineAlpha) + color.setAlpha(int(lineAlpha)) tickPen.setColor(color) tickSpecs.append((tickPen, Point(p1), Point(p2))) profiler('compute ticks') - + if self.style['stopAxisAtTick'][0] is True: - stop = max(span[0].y(), min(map(min, tickPositions))) + minTickPosition = min(map(min, tickPositions)) if axis == 0: + stop = max(span[0].y(), minTickPosition) span[0].setY(stop) else: + stop = max(span[0].x(), minTickPosition) span[0].setX(stop) if self.style['stopAxisAtTick'][1] is True: - stop = min(span[1].y(), max(map(max, tickPositions))) + maxTickPosition = max(map(max, tickPositions)) if axis == 0: + stop = min(span[1].y(), maxTickPosition) span[1].setY(stop) else: + stop = min(span[1].x(), maxTickPosition) span[1].setX(stop) axisSpec = (self.pen(), span[0], span[1]) - + textOffset = self.style['tickTextOffset'][axis] ## spacing between axis and text #if self.style['autoExpandTextSpace'] is True: #textWidth = self.textWidth @@ -910,15 +997,15 @@ def generateDrawSpecs(self, p): #else: #textWidth = self.style['tickTextWidth'] ## space allocated for horizontal text #textHeight = self.style['tickTextHeight'] ## space allocated for horizontal text - + textSize2 = 0 textRects = [] textSpecs = [] ## list of draw - + # If values are hidden, return early if not self.style['showValues']: return (axisSpec, tickSpecs, textSpecs) - + for i in range(min(len(tickLevels), self.style['maxTextLevel']+1)): ## Get the list of strings to display for this level if tickStrings is None: @@ -926,10 +1013,10 @@ def generateDrawSpecs(self, p): strings = self.tickStrings(values, self.autoSIPrefixScale * self.scale, spacing) else: strings = tickStrings[i] - + if len(strings) == 0: continue - + ## ignore strings belonging to ticks that were previously ignored for j in range(len(strings)): if tickPositions[i][j] is None: @@ -945,10 +1032,10 @@ def generateDrawSpecs(self, p): ## boundingRect is usually just a bit too large ## (but this probably depends on per-font metrics?) br.setHeight(br.height() * 0.8) - + rects.append(br) textRects.append(rects[-1]) - + if len(textRects) > 0: ## measure all text, make sure there's enough room if axis == 0: @@ -973,7 +1060,7 @@ def generateDrawSpecs(self, p): break if finished: break - + #spacing, values = tickLevels[best] #strings = self.tickStrings(values, self.scale, spacing) # Determine exactly where tick text should be drawn @@ -1006,37 +1093,37 @@ def generateDrawSpecs(self, p): #p.drawText(rect, textFlags, vstr) textSpecs.append((rect, textFlags, vstr)) profiler('compute text') - + ## update max text size if needed. self._updateMaxTextSize(textSize2) - + return (axisSpec, tickSpecs, textSpecs) - + def drawPicture(self, p, axisSpec, tickSpecs, textSpecs): profiler = debug.Profiler() p.setRenderHint(p.Antialiasing, False) p.setRenderHint(p.TextAntialiasing, True) - + ## draw long line along axis pen, p1, p2 = axisSpec p.setPen(pen) p.drawLine(p1, p2) p.translate(0.5,0) ## resolves some damn pixel ambiguity - + ## draw ticks for pen, p1, p2 in tickSpecs: p.setPen(pen) p.drawLine(p1, p2) profiler('draw ticks') - ## Draw all text - if self.tickFont is not None: - p.setFont(self.tickFont) - p.setPen(self.pen()) + # Draw all text + if self.style['tickFont'] is not None: + p.setFont(self.style['tickFont']) + p.setPen(self.textPen()) for rect, flags, text in textSpecs: - p.drawText(rect, flags, text) - #p.drawRect(rect) + p.drawText(rect, int(flags), text) + profiler('draw text') def show(self): @@ -1045,7 +1132,7 @@ def show(self): self._updateWidth() else: self._updateHeight() - + def hide(self): GraphicsWidget.hide(self) if self.orientation in ['left', 'right']: @@ -1054,23 +1141,26 @@ def hide(self): self._updateHeight() def wheelEvent(self, ev): - if self.linkedView() is None: + lv = self.linkedView() + if lv is None: return if self.orientation in ['left', 'right']: - self.linkedView().wheelEvent(ev, axis=1) + lv.wheelEvent(ev, axis=1) else: - self.linkedView().wheelEvent(ev, axis=0) + lv.wheelEvent(ev, axis=0) ev.accept() - + def mouseDragEvent(self, event): - if self.linkedView() is None: + lv = self.linkedView() + if lv is None: return if self.orientation in ['left', 'right']: - return self.linkedView().mouseDragEvent(event, axis=1) + return lv.mouseDragEvent(event, axis=1) else: - return self.linkedView().mouseDragEvent(event, axis=0) - + return lv.mouseDragEvent(event, axis=0) + def mouseClickEvent(self, event): - if self.linkedView() is None: + lv = self.linkedView() + if lv is None: return - return self.linkedView().mouseClickEvent(event) + return lv.mouseClickEvent(event) diff --git a/pyqtgraph/graphicsItems/BarGraphItem.py b/pyqtgraph/graphicsItems/BarGraphItem.py index a1d5d02957..4e820cb8e8 100644 --- a/pyqtgraph/graphicsItems/BarGraphItem.py +++ b/pyqtgraph/graphicsItems/BarGraphItem.py @@ -40,6 +40,7 @@ def __init__(self, **opts): y0=None, x1=None, y1=None, + name=None, height=None, width=None, pen=None, @@ -120,7 +121,7 @@ def asarray(x): p.setPen(fn.mkPen(pen)) p.setBrush(fn.mkBrush(brush)) - for i in range(len(x0)): + for i in range(len(x0 if not np.isscalar(x0) else y0)): if pens is not None: p.setPen(fn.mkPen(pens[i])) if brushes is not None: @@ -166,3 +167,15 @@ def shape(self): if self.picture is None: self.drawPicture() return self._shape + + def implements(self, interface=None): + ints = ['plotData'] + if interface is None: + return ints + return interface in ints + + def name(self): + return self.opts.get('name', None) + + def getData(self): + return self.opts.get('x'), self.opts.get('height') diff --git a/pyqtgraph/graphicsItems/DateAxisItem.py b/pyqtgraph/graphicsItems/DateAxisItem.py new file mode 100644 index 0000000000..a5132fd993 --- /dev/null +++ b/pyqtgraph/graphicsItems/DateAxisItem.py @@ -0,0 +1,318 @@ +import sys +import numpy as np +import time +from datetime import datetime, timedelta + +from .AxisItem import AxisItem +from ..pgcollections import OrderedDict + +__all__ = ['DateAxisItem'] + +MS_SPACING = 1/1000.0 +SECOND_SPACING = 1 +MINUTE_SPACING = 60 +HOUR_SPACING = 3600 +DAY_SPACING = 24 * HOUR_SPACING +WEEK_SPACING = 7 * DAY_SPACING +MONTH_SPACING = 30 * DAY_SPACING +YEAR_SPACING = 365 * DAY_SPACING + +if sys.platform == 'win32': + _epoch = datetime.utcfromtimestamp(0) + def utcfromtimestamp(timestamp): + return _epoch + timedelta(seconds=timestamp) +else: + utcfromtimestamp = datetime.utcfromtimestamp + +MIN_REGULAR_TIMESTAMP = (datetime(1, 1, 1) - datetime(1970,1,1)).total_seconds() +MAX_REGULAR_TIMESTAMP = (datetime(9999, 1, 1) - datetime(1970,1,1)).total_seconds() +SEC_PER_YEAR = 365.25*24*3600 + +def makeMSStepper(stepSize): + def stepper(val, n): + if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP: + return np.inf + + val *= 1000 + f = stepSize * 1000 + return (val // (n*f) + 1) * (n*f) / 1000.0 + return stepper + +def makeSStepper(stepSize): + def stepper(val, n): + if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP: + return np.inf + + return (val // (n*stepSize) + 1) * (n*stepSize) + return stepper + +def makeMStepper(stepSize): + def stepper(val, n): + if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP: + return np.inf + + d = utcfromtimestamp(val) + base0m = (d.month + n*stepSize - 1) + d = datetime(d.year + base0m // 12, base0m % 12 + 1, 1) + return (d - datetime(1970, 1, 1)).total_seconds() + return stepper + +def makeYStepper(stepSize): + def stepper(val, n): + if val < MIN_REGULAR_TIMESTAMP or val > MAX_REGULAR_TIMESTAMP: + return np.inf + + d = utcfromtimestamp(val) + next_year = (d.year // (n*stepSize) + 1) * (n*stepSize) + if next_year > 9999: + return np.inf + next_date = datetime(next_year, 1, 1) + return (next_date - datetime(1970, 1, 1)).total_seconds() + return stepper + +class TickSpec: + """ Specifies the properties for a set of date ticks and computes ticks + within a given utc timestamp range """ + def __init__(self, spacing, stepper, format, autoSkip=None): + """ + ============= ========================================================== + Arguments + spacing approximate (average) tick spacing + stepper a stepper function that takes a utc time stamp and a step + steps number n to compute the start of the next unit. You + can use the make_X_stepper functions to create common + steppers. + format a strftime compatible format string which will be used to + convert tick locations to date/time strings + autoSkip list of step size multipliers to be applied when the tick + density becomes too high. The tick spec automatically + applies additional powers of 10 (10, 100, ...) to the list + if necessary. Set to None to switch autoSkip off + ============= ========================================================== + + """ + self.spacing = spacing + self.step = stepper + self.format = format + self.autoSkip = autoSkip + + def makeTicks(self, minVal, maxVal, minSpc): + ticks = [] + n = self.skipFactor(minSpc) + x = self.step(minVal, n) + while x <= maxVal: + ticks.append(x) + x = self.step(x, n) + return (np.array(ticks), n) + + def skipFactor(self, minSpc): + if self.autoSkip is None or minSpc < self.spacing: + return 1 + factors = np.array(self.autoSkip, dtype=np.float) + while True: + for f in factors: + spc = self.spacing * f + if spc > minSpc: + return int(f) + factors *= 10 + + +class ZoomLevel: + """ Generates the ticks which appear in a specific zoom level """ + def __init__(self, tickSpecs, exampleText): + """ + ============= ========================================================== + tickSpecs a list of one or more TickSpec objects with decreasing + coarseness + ============= ========================================================== + + """ + self.tickSpecs = tickSpecs + self.utcOffset = 0 + self.exampleText = exampleText + + def tickValues(self, minVal, maxVal, minSpc): + # return tick values for this format in the range minVal, maxVal + # the return value is a list of tuples (, [tick positions]) + # minSpc indicates the minimum spacing (in seconds) between two ticks + # to fullfill the maxTicksPerPt constraint of the DateAxisItem at the + # current zoom level. This is used for auto skipping ticks. + allTicks = [] + valueSpecs = [] + # back-project (minVal maxVal) to UTC, compute ticks then offset to + # back to local time again + utcMin = minVal - self.utcOffset + utcMax = maxVal - self.utcOffset + for spec in self.tickSpecs: + ticks, skipFactor = spec.makeTicks(utcMin, utcMax, minSpc) + # reposition tick labels to local time coordinates + ticks += self.utcOffset + # remove any ticks that were present in higher levels + tick_list = [x for x in ticks.tolist() if x not in allTicks] + allTicks.extend(tick_list) + valueSpecs.append((spec.spacing, tick_list)) + # if we're skipping ticks on the current level there's no point in + # producing lower level ticks + if skipFactor > 1: + break + return valueSpecs + + +YEAR_MONTH_ZOOM_LEVEL = ZoomLevel([ + TickSpec(YEAR_SPACING, makeYStepper(1), '%Y', autoSkip=[1, 5, 10, 25]), + TickSpec(MONTH_SPACING, makeMStepper(1), '%b') +], "YYYY") +MONTH_DAY_ZOOM_LEVEL = ZoomLevel([ + TickSpec(MONTH_SPACING, makeMStepper(1), '%b'), + TickSpec(DAY_SPACING, makeSStepper(DAY_SPACING), '%d', autoSkip=[1, 5]) +], "MMM") +DAY_HOUR_ZOOM_LEVEL = ZoomLevel([ + TickSpec(DAY_SPACING, makeSStepper(DAY_SPACING), '%a %d'), + TickSpec(HOUR_SPACING, makeSStepper(HOUR_SPACING), '%H:%M', autoSkip=[1, 6]) +], "MMM 00") +HOUR_MINUTE_ZOOM_LEVEL = ZoomLevel([ + TickSpec(DAY_SPACING, makeSStepper(DAY_SPACING), '%a %d'), + TickSpec(MINUTE_SPACING, makeSStepper(MINUTE_SPACING), '%H:%M', + autoSkip=[1, 5, 15]) +], "MMM 00") +HMS_ZOOM_LEVEL = ZoomLevel([ + TickSpec(SECOND_SPACING, makeSStepper(SECOND_SPACING), '%H:%M:%S', + autoSkip=[1, 5, 15, 30]) +], "99:99:99") +MS_ZOOM_LEVEL = ZoomLevel([ + TickSpec(MINUTE_SPACING, makeSStepper(MINUTE_SPACING), '%H:%M:%S'), + TickSpec(MS_SPACING, makeMSStepper(MS_SPACING), '%S.%f', + autoSkip=[1, 5, 10, 25]) +], "99:99:99") + +class DateAxisItem(AxisItem): + """ + **Bases:** :class:`AxisItem ` + + An AxisItem that displays dates from unix timestamps. + + The display format is adjusted automatically depending on the current time + density (seconds/point) on the axis. For more details on changing this + behaviour, see :func:`setZoomLevelForDensity() `. + + Can be added to an existing plot e.g. via + :func:`setAxisItems({'bottom':axis}) `. + + """ + + def __init__(self, orientation='bottom', **kwargs): + """ + Create a new DateAxisItem. + + For `orientation` and `**kwargs`, see + :func:`AxisItem.__init__ `. + + """ + + super(DateAxisItem, self).__init__(orientation, **kwargs) + # Set the zoom level to use depending on the time density on the axis + self.utcOffset = time.timezone + + self.zoomLevels = OrderedDict([ + (np.inf, YEAR_MONTH_ZOOM_LEVEL), + (5 * 3600*24, MONTH_DAY_ZOOM_LEVEL), + (6 * 3600, DAY_HOUR_ZOOM_LEVEL), + (15 * 60, HOUR_MINUTE_ZOOM_LEVEL), + (30, HMS_ZOOM_LEVEL), + (1, MS_ZOOM_LEVEL), + ]) + + def tickStrings(self, values, scale, spacing): + tickSpecs = self.zoomLevel.tickSpecs + tickSpec = next((s for s in tickSpecs if s.spacing == spacing), None) + try: + dates = [utcfromtimestamp(v - self.utcOffset) for v in values] + except (OverflowError, ValueError, OSError): + # should not normally happen + return ['%g' % ((v-self.utcOffset)//SEC_PER_YEAR + 1970) for v in values] + + formatStrings = [] + for x in dates: + try: + s = x.strftime(tickSpec.format) + if '%f' in tickSpec.format: + # we only support ms precision + s = s[:-3] + elif '%Y' in tickSpec.format: + s = s.lstrip('0') + formatStrings.append(s) + except ValueError: # Windows can't handle dates before 1970 + formatStrings.append('') + return formatStrings + + def tickValues(self, minVal, maxVal, size): + density = (maxVal - minVal) / size + self.setZoomLevelForDensity(density) + values = self.zoomLevel.tickValues(minVal, maxVal, minSpc=self.minSpacing) + return values + + def setZoomLevelForDensity(self, density): + """ + Setting `zoomLevel` and `minSpacing` based on given density of seconds per pixel + + The display format is adjusted automatically depending on the current time + density (seconds/point) on the axis. You can customize the behaviour by + overriding this function or setting a different set of zoom levels + than the default one. The `zoomLevels` variable is a dictionary with the + maximal distance of ticks in seconds which are allowed for each zoom level + before the axis switches to the next coarser level. To customize the zoom level + selection, override this function. + """ + padding = 10 + + # Size in pixels a specific tick label will take + if self.orientation in ['bottom', 'top']: + def sizeOf(text): + return self.fontMetrics.boundingRect(text).width() + padding*self.fontScaleFactor + else: + def sizeOf(text): + return self.fontMetrics.boundingRect(text).height() + padding*self.fontScaleFactor + + # Fallback zoom level: Years/Months + self.zoomLevel = YEAR_MONTH_ZOOM_LEVEL + for maximalSpacing, zoomLevel in self.zoomLevels.items(): + size = sizeOf(zoomLevel.exampleText) + + # Test if zoom level is too fine grained + if maximalSpacing/size < density: + break + + self.zoomLevel = zoomLevel + + # Set up zoomLevel + self.zoomLevel.utcOffset = self.utcOffset + + # Calculate minimal spacing of items on the axis + size = sizeOf(self.zoomLevel.exampleText) + self.minSpacing = density*size + + def linkToView(self, view): + super(DateAxisItem, self).linkToView(view) + + # Set default limits + _min = MIN_REGULAR_TIMESTAMP + _max = MAX_REGULAR_TIMESTAMP + + if self.orientation in ['right', 'left']: + view.setLimits(yMin=_min, yMax=_max) + else: + view.setLimits(xMin=_min, xMax=_max) + + def generateDrawSpecs(self, p): + # Get font metrics from QPainter + # Not happening in "paint", as the QPainter p there is a different one from the one here, + # so changing that font could cause unwanted side effects + if self.style['tickFont'] is not None: + p.setFont(self.style['tickFont']) + + self.fontMetrics = p.fontMetrics() + + # Get font scale factor by current window resolution + self.fontScaleFactor = p.device().logicalDpiX() / 96 + + return super(DateAxisItem, self).generateDrawSpecs(p) diff --git a/pyqtgraph/graphicsItems/ErrorBarItem.py b/pyqtgraph/graphicsItems/ErrorBarItem.py index 986c5140da..b79da6f778 100644 --- a/pyqtgraph/graphicsItems/ErrorBarItem.py +++ b/pyqtgraph/graphicsItems/ErrorBarItem.py @@ -23,6 +23,7 @@ def __init__(self, **opts): beam=None, pen=None ) + self.setVisible(False) self.setData(**opts) def setData(self, **opts): @@ -45,6 +46,7 @@ def setData(self, **opts): This method was added in version 0.9.9. For prior versions, use setOpts. """ self.opts.update(opts) + self.setVisible(all(self.opts[ax] is not None for ax in ['x', 'y'])) self.path = None self.update() self.prepareGeometryChange() @@ -59,6 +61,7 @@ def drawPath(self): x, y = self.opts['x'], self.opts['y'] if x is None or y is None: + self.path = p return beam = self.opts['beam'] @@ -146,4 +149,4 @@ def boundingRect(self): self.drawPath() return self.path.boundingRect() - \ No newline at end of file + diff --git a/pyqtgraph/graphicsItems/FillBetweenItem.py b/pyqtgraph/graphicsItems/FillBetweenItem.py index 0efb11dd15..b16be85395 100644 --- a/pyqtgraph/graphicsItems/FillBetweenItem.py +++ b/pyqtgraph/graphicsItems/FillBetweenItem.py @@ -1,4 +1,4 @@ -from ..Qt import QtGui, USE_PYQT5, USE_PYQT4, USE_PYSIDE +from ..Qt import QtGui from .. import functions as fn from .PlotDataItem import PlotDataItem from .PlotCurveItem import PlotCurveItem diff --git a/pyqtgraph/graphicsItems/GradientEditorItem.py b/pyqtgraph/graphicsItems/GradientEditorItem.py index 6ce06b6174..942b280b1e 100644 --- a/pyqtgraph/graphicsItems/GradientEditorItem.py +++ b/pyqtgraph/graphicsItems/GradientEditorItem.py @@ -1,14 +1,14 @@ +# -*- coding: utf-8 -*- +import operator import weakref import numpy as np from ..Qt import QtGui, QtCore -from ..python2_3 import sortList from .. import functions as fn from .GraphicsObject import GraphicsObject from .GraphicsWidget import GraphicsWidget from ..widgets.SpinBox import SpinBox from ..pgcollections import OrderedDict from ..colormap import ColorMap -from ..python2_3 import cmp __all__ = ['TickSliderItem', 'GradientEditorItem'] @@ -22,12 +22,18 @@ ('cyclic', {'ticks': [(0.0, (255, 0, 4, 255)), (1.0, (255, 0, 0, 255))], 'mode': 'hsv'}), ('greyclip', {'ticks': [(0.0, (0, 0, 0, 255)), (0.99, (255, 255, 255, 255)), (1.0, (255, 0, 0, 255))], 'mode': 'rgb'}), ('grey', {'ticks': [(0.0, (0, 0, 0, 255)), (1.0, (255, 255, 255, 255))], 'mode': 'rgb'}), + # Perceptually uniform sequential colormaps from Matplotlib 2.0 + ('viridis', {'ticks': [(0.0, (68, 1, 84, 255)), (0.25, (58, 82, 139, 255)), (0.5, (32, 144, 140, 255)), (0.75, (94, 201, 97, 255)), (1.0, (253, 231, 36, 255))], 'mode': 'rgb'}), + ('inferno', {'ticks': [(0.0, (0, 0, 3, 255)), (0.25, (87, 15, 109, 255)), (0.5, (187, 55, 84, 255)), (0.75, (249, 142, 8, 255)), (1.0, (252, 254, 164, 255))], 'mode': 'rgb'}), + ('plasma', {'ticks': [(0.0, (12, 7, 134, 255)), (0.25, (126, 3, 167, 255)), (0.5, (203, 71, 119, 255)), (0.75, (248, 149, 64, 255)), (1.0, (239, 248, 33, 255))], 'mode': 'rgb'}), + ('magma', {'ticks': [(0.0, (0, 0, 3, 255)), (0.25, (80, 18, 123, 255)), (0.5, (182, 54, 121, 255)), (0.75, (251, 136, 97, 255)), (1.0, (251, 252, 191, 255))], 'mode': 'rgb'}), ]) def addGradientListToDocstring(): """Decorator to add list of current pre-defined gradients to the end of a function docstring.""" def dec(fn): - fn.__doc__ = fn.__doc__ + str(Gradients.keys()).strip('[').strip(']') + if fn.__doc__ is not None: + fn.__doc__ = fn.__doc__ + str(Gradients.keys()).strip('[').strip(']') return fn return dec @@ -346,8 +352,7 @@ def getTick(self, tick): def listTicks(self): """Return a sorted list of all the Tick objects on the slider.""" ## public - ticks = list(self.ticks.items()) - sortList(ticks, lambda a,b: cmp(a[1], b[1])) ## see pyqtgraph.python2_3.sortList + ticks = sorted(self.ticks.items(), key=operator.itemgetter(1)) return ticks @@ -433,8 +438,14 @@ def __init__(self, *args, **kargs): label = QtGui.QLabel() label.setPixmap(px) label.setContentsMargins(1, 1, 1, 1) + labelName = QtGui.QLabel(g) + hbox = QtGui.QHBoxLayout() + hbox.addWidget(labelName) + hbox.addWidget(label) + widget = QtGui.QWidget() + widget.setLayout(hbox) act = QtGui.QWidgetAction(self) - act.setDefaultWidget(label) + act.setDefaultWidget(widget) act.triggered.connect(self.contextMenuClicked) act.name = g self.menu.addAction(act) @@ -450,7 +461,20 @@ def __init__(self, *args, **kargs): self.addTick(1, QtGui.QColor(255,0,0), True) self.setColorMode('rgb') self.updateGradient() - + self.linkedGradients = {} + + def showTicks(self, show=True): + for tick in self.ticks.keys(): + if show: + tick.show() + orig = getattr(self, '_allowAdd_backup', None) + if orig: + self.allowAdd = orig + else: + self._allowAdd_backup = self.allowAdd + self.allowAdd = False #block tick creation + tick.hide() + def setOrientation(self, orientation): ## public """ @@ -649,7 +673,7 @@ def getColor(self, x, toQColor=True): s = s1 * (1.-f) + s2 * f v = v1 * (1.-f) + v2 * f c = QtGui.QColor() - c.setHsv(h,s,v) + c.setHsv(*map(int, [h,s,v])) if toQColor: return c else: @@ -753,7 +777,9 @@ def saveState(self): for t in self.ticks: c = t.color ticks.append((self.ticks[t], (c.red(), c.green(), c.blue(), c.alpha()))) - state = {'mode': self.colorMode, 'ticks': ticks} + state = {'mode': self.colorMode, + 'ticks': ticks, + 'ticksVisible': next(iter(self.ticks)).isVisible()} return state def restoreState(self, state): @@ -778,6 +804,8 @@ def restoreState(self, state): for t in state['ticks']: c = QtGui.QColor(*t[1]) self.addTick(t[0], c, finish=False) + self.showTicks( state.get('ticksVisible', + next(iter(self.ticks)).isVisible()) ) self.updateGradient() self.sigGradientChangeFinished.emit(self) @@ -793,6 +821,18 @@ def setColorMap(self, cm): self.updateGradient() self.sigGradientChangeFinished.emit(self) + def linkGradient(self, slaveGradient, connect=True): + if connect: + fn = lambda g, slave=slaveGradient:slave.restoreState( + g.saveState()) + self.linkedGradients[id(slaveGradient)] = fn + self.sigGradientChanged.connect(fn) + self.sigGradientChanged.emit(self) + else: + fn = self.linkedGradients.get(id(slaveGradient), None) + if fn: + self.sigGradientChanged.disconnect(fn) + class Tick(QtGui.QGraphicsWidget): ## NOTE: Making this a subclass of GraphicsObject instead results in ## activating this bug: https://bugreports.qt-project.org/browse/PYSIDE-86 @@ -863,8 +903,8 @@ def mouseDragEvent(self, ev): self.view().tickMoveFinished(self) def mouseClickEvent(self, ev): - if ev.button() == QtCore.Qt.RightButton and self.moving: - ev.accept() + ev.accept() + if ev.button() == QtCore.Qt.RightButton and self.moving: self.setPos(self.startPosition) self.view().tickMoved(self, self.startPosition) self.moving = False @@ -872,7 +912,6 @@ def mouseClickEvent(self, ev): self.sigMoved.emit(self) else: self.view().tickClicked(self, ev) - ##remove def hoverEvent(self, ev): if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): @@ -938,4 +977,3 @@ def fractionalValueChanged(self, x): # self.fracPosSpin.blockSignals(True) # self.fracPosSpin.setValue(self.sliderItem().tickValue(self.tick())) # self.fracPosSpin.blockSignals(False) - diff --git a/pyqtgraph/graphicsItems/GraphicsItem.py b/pyqtgraph/graphicsItems/GraphicsItem.py index d45818dc55..1a522446c2 100644 --- a/pyqtgraph/graphicsItems/GraphicsItem.py +++ b/pyqtgraph/graphicsItems/GraphicsItem.py @@ -19,8 +19,9 @@ class GraphicsItem(object): The GraphicsView system places a lot of emphasis on the notion that the graphics within the scene should be device independent--you should be able to take the same graphics and display them on screens of different resolutions, printers, export to SVG, etc. This is nice in principle, but causes me a lot of headache in practice. It means that I have to circumvent all the device-independent expectations any time I want to operate in pixel coordinates rather than arbitrary scene coordinates. A lot of the code in GraphicsItem is devoted to this task--keeping track of view widgets and device transforms, computing the size and shape of a pixel in local item coordinates, etc. Note that in item coordinates, a pixel does not have to be square or even rectangular, so just asking how to increase a bounding rect by 2px can be a rather complex task. """ _pixelVectorGlobalCache = LRUCache(100, 70) - - def __init__(self, register=True): + _mapRectFromViewGlobalCache = LRUCache(100, 70) + + def __init__(self, register=None): if not hasattr(self, '_qtBaseClass'): for b in self.__class__.__bases__: if issubclass(b, QtGui.QGraphicsItem): @@ -28,15 +29,18 @@ def __init__(self, register=True): break if not hasattr(self, '_qtBaseClass'): raise Exception('Could not determine Qt base class for GraphicsItem: %s' % str(self)) - + self._pixelVectorCache = [None, None] self._viewWidget = None self._viewBox = None self._connectedView = None self._exportOpts = False ## If False, not currently exporting. Otherwise, contains dict of export options. - if register: - GraphicsScene.registerObject(self) ## workaround for pyqt bug in graphicsscene.items() - + if register is not None and register: + warnings.warn( + "'register' argument is deprecated and does nothing", + DeprecationWarning, stacklevel=2 + ) + def getViewWidget(self): """ Return the view widget for this item. @@ -98,8 +102,9 @@ def deviceTransform(self, viewportTransform=None): Extends deviceTransform to automatically determine the viewportTransform. """ if self._exportOpts is not False and 'painter' in self._exportOpts: ## currently exporting; device transform may be different. - return self._exportOpts['painter'].deviceTransform() * self.sceneTransform() - + scaler = self._exportOpts.get('resolutionScale', 1.0) + return self.sceneTransform() * QtGui.QTransform(scaler, 0, 0, scaler, 1, 1) + if viewportTransform is None: view = self.getViewWidget() if view is None: @@ -146,7 +151,8 @@ def getBoundingParents(self): return parents def viewRect(self): - """Return the bounds (in item coordinates) of this item's ViewBox or GraphicsWidget""" + """Return the visible bounds of this item's ViewBox or GraphicsWidget, + in the local coordinate system of the item.""" view = self.getViewBox() if view is None: return None @@ -183,24 +189,23 @@ def pixelVectors(self, direction=None): ## (such as when looking at unix timestamps), we can get floating-point errors. dt.setMatrix(dt.m11(), dt.m12(), 0, dt.m21(), dt.m22(), 0, 0, 0, 1) + if direction is None: + direction = QtCore.QPointF(1, 0) + elif direction.manhattanLength() == 0: + raise Exception("Cannot compute pixel length for 0-length vector.") + + key = (dt.m11(), dt.m21(), dt.m12(), dt.m22(), direction.x(), direction.y()) + ## check local cache - if direction is None and dt == self._pixelVectorCache[0]: + if key == self._pixelVectorCache[0]: return tuple(map(Point, self._pixelVectorCache[1])) ## return a *copy* - + ## check global cache - #key = (dt.m11(), dt.m21(), dt.m31(), dt.m12(), dt.m22(), dt.m32(), dt.m31(), dt.m32()) - key = (dt.m11(), dt.m21(), dt.m12(), dt.m22()) pv = self._pixelVectorGlobalCache.get(key, None) - if direction is None and pv is not None: - self._pixelVectorCache = [dt, pv] + if pv is not None: + self._pixelVectorCache = [key, pv] return tuple(map(Point,pv)) ## return a *copy* - - if direction is None: - direction = QtCore.QPointF(1, 0) - if direction.manhattanLength() == 0: - raise Exception("Cannot compute pixel length for 0-length vector.") - ## attempt to re-scale direction vector to fit within the precision of the coordinate system ## Here's the problem: we need to map the vector 'direction' from the item to the device, via transform 'dt'. ## In some extreme cases, this mapping can fail unless the length of 'direction' is cleverly chosen. @@ -363,8 +368,21 @@ def mapRectFromView(self, obj): vt = self.viewTransform() if vt is None: return None - vt = fn.invertQTransform(vt) - return vt.mapRect(obj) + + cache = self._mapRectFromViewGlobalCache + k = ( + vt.m11(), vt.m12(), vt.m13(), + vt.m21(), vt.m22(), vt.m23(), + vt.m31(), vt.m32(), vt.m33(), + ) + + try: + inv_vt = cache[k] + except KeyError: + inv_vt = fn.invertQTransform(vt) + cache[k] = inv_vt + + return inv_vt.mapRect(obj) def pos(self): return Point(self._qtBaseClass.pos(self)) @@ -444,6 +462,10 @@ def _updateView(self): ## called to see whether this item has a new view to connect to ## NOTE: This is called from GraphicsObject.itemChange or GraphicsWidget.itemChange. + if not hasattr(self, '_connectedView'): + # Happens when Python is shutting down. + return + ## It is possible this item has moved to a different ViewBox or widget; ## clear out previously determined references to these. self.forgetViewBox() diff --git a/pyqtgraph/graphicsItems/GraphicsLayout.py b/pyqtgraph/graphicsItems/GraphicsLayout.py index 6ec38fb577..9c209352c7 100644 --- a/pyqtgraph/graphicsItems/GraphicsLayout.py +++ b/pyqtgraph/graphicsItems/GraphicsLayout.py @@ -23,6 +23,7 @@ def __init__(self, parent=None, border=None): self.setLayout(self.layout) self.items = {} ## item: [(row, col), (row, col), ...] lists all cells occupied by the item self.rows = {} ## row: {col1: item1, col2: item2, ...} maps cell location to item + self.itemBorders = {} ## {item1: QtGui.QGraphicsRectItem, ...} border rects self.currentRow = 0 self.currentCol = 0 self.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)) @@ -39,8 +40,10 @@ def setBorder(self, *args, **kwds): See :func:`mkPen ` for arguments. """ self.border = fn.mkPen(*args, **kwds) - self.update() - + + for borderRect in self.itemBorders.values(): + borderRect.setPen(self.border) + def nextRow(self): """Advance to next row for automatic item placement""" self.currentRow += 1 @@ -119,8 +122,21 @@ def addItem(self, item, row=None, col=None, rowspan=1, colspan=1): self.rows[row2] = {} self.rows[row2][col2] = item self.items[item].append((row2, col2)) - + + borderRect = QtGui.QGraphicsRectItem() + + borderRect.setParentItem(self) + borderRect.setZValue(1e3) + borderRect.setPen(fn.mkPen(self.border)) + + self.itemBorders[item] = borderRect + + item.geometryChanged.connect(self._updateItemBorder) + self.layout.addItem(item, row, col, rowspan, colspan) + self.layout.activate() # Update layout, recalculating bounds. + # Allows some PyQtGraph features to also work without Qt event loop. + self.nextColumn() def getItem(self, row, col): @@ -129,15 +145,7 @@ def getItem(self, row, col): def boundingRect(self): return self.rect() - - def paint(self, p, *args): - if self.border is None: - return - p.setPen(fn.mkPen(self.border)) - for i in self.items: - r = i.mapRectToParent(i.boundingRect()) - p.drawRect(r) - + def itemIndex(self, item): for i in range(self.layout.count()): if self.layout.itemAt(i).graphicsItem() is item: @@ -150,15 +158,20 @@ def removeItem(self, item): self.layout.removeAt(ind) self.scene().removeItem(item) - for r,c in self.items[item]: + for r, c in self.items[item]: del self.rows[r][c] del self.items[item] + + item.geometryChanged.disconnect(self._updateItemBorder) + del self.itemBorders[item] + self.update() def clear(self): - items = [] for i in list(self.items.keys()): self.removeItem(i) + self.currentRow = 0 + self.currentCol = 0 def setContentsMargins(self, *args): # Wrap calls to layout. This should happen automatically, but there @@ -168,4 +181,14 @@ def setContentsMargins(self, *args): def setSpacing(self, *args): self.layout.setSpacing(*args) - \ No newline at end of file + + def _updateItemBorder(self): + if self.border is None: + return + + item = self.sender() + if item is None: + return + + r = item.mapRectToParent(item.boundingRect()) + self.itemBorders[item].setRect(r) diff --git a/pyqtgraph/graphicsItems/GraphicsObject.py b/pyqtgraph/graphicsItems/GraphicsObject.py index 015a78c660..2493fe76ff 100644 --- a/pyqtgraph/graphicsItems/GraphicsObject.py +++ b/pyqtgraph/graphicsItems/GraphicsObject.py @@ -1,5 +1,5 @@ -from ..Qt import QtGui, QtCore, USE_PYSIDE -if not USE_PYSIDE: +from ..Qt import QtGui, QtCore, QT_LIB +if QT_LIB in ['PyQt4', 'PyQt5']: import sip from .GraphicsItem import GraphicsItem @@ -33,7 +33,7 @@ def itemChange(self, change, value): ## workaround for pyqt bug: ## http://www.riverbankcomputing.com/pipermail/pyqt/2012-August/031818.html - if not USE_PYSIDE and change == self.ItemParentChange and isinstance(ret, QtGui.QGraphicsItem): + if QT_LIB in ['PyQt4', 'PyQt5'] and change == self.ItemParentChange and isinstance(ret, QtGui.QGraphicsItem): ret = sip.cast(ret, QtGui.QGraphicsItem) return ret diff --git a/pyqtgraph/graphicsItems/GridItem.py b/pyqtgraph/graphicsItems/GridItem.py index 87f90a6281..db64cbbfc8 100644 --- a/pyqtgraph/graphicsItems/GridItem.py +++ b/pyqtgraph/graphicsItems/GridItem.py @@ -3,6 +3,7 @@ import numpy as np from ..Point import Point from .. import functions as fn +from .. import getConfigOption __all__ = ['GridItem'] class GridItem(UIGraphicsItem): @@ -12,16 +13,75 @@ class GridItem(UIGraphicsItem): Displays a rectangular grid of lines indicating major divisions within a coordinate system. Automatically determines what divisions to use. """ - - def __init__(self): + + def __init__(self, pen='default', textPen='default'): UIGraphicsItem.__init__(self) #QtGui.QGraphicsItem.__init__(self, *args) #self.setFlag(QtGui.QGraphicsItem.ItemClipsToShape) #self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) - + + self.opts = {} + + self.setPen(pen) + self.setTextPen(textPen) + self.setTickSpacing(x=[None, None, None], y=[None, None, None]) + + + def setPen(self, *args, **kwargs): + """Set the pen used to draw the grid.""" + if kwargs == {} and (args == () or args == ('default',)): + self.opts['pen'] = fn.mkPen(getConfigOption('foreground')) + else: + self.opts['pen'] = fn.mkPen(*args, **kwargs) + self.picture = None - - + self.update() + + + def setTextPen(self, *args, **kwargs): + """Set the pen used to draw the texts.""" + if kwargs == {} and (args == () or args == ('default',)): + self.opts['textPen'] = fn.mkPen(getConfigOption('foreground')) + else: + if args == (None,): + self.opts['textPen'] = None + else: + self.opts['textPen'] = fn.mkPen(*args, **kargs) + + self.picture = None + self.update() + + + def setTickSpacing(self, x=None, y=None): + """ + Set the grid tick spacing to use. + + Tick spacing for each axis shall be specified as an array of + descending values, one for each tick scale. When the value + is set to None, grid line distance is chosen automatically + for this particular level. + + Example: + Default setting of 3 scales for each axis: + setTickSpacing(x=[None, None, None], y=[None, None, None]) + + Single scale with distance of 1.0 for X axis, Two automatic + scales for Y axis: + setTickSpacing(x=[1.0], y=[None, None]) + + Single scale with distance of 1.0 for X axis, Two scales + for Y axis, one with spacing of 1.0, other one automatic: + setTickSpacing(x=[1.0], y=[1.0, None]) + """ + self.opts['tickSpacing'] = (x or self.opts['tickSpacing'][0], + y or self.opts['tickSpacing'][1]) + + self.grid_depth = max([len(s) for s in self.opts['tickSpacing']]) + + self.picture = None + self.update() + + def viewRangeChanged(self): UIGraphicsItem.viewRangeChanged(self) self.picture = None @@ -48,7 +108,6 @@ def generatePicture(self): p = QtGui.QPainter() p.begin(self.picture) - dt = fn.invertQTransform(self.viewTransform()) vr = self.getViewWidget().rect() unit = self.pixelWidth(), self.pixelHeight() dim = [vr.width(), vr.height()] @@ -62,10 +121,22 @@ def generatePicture(self): x = ul[1] ul[1] = br[1] br[1] = x - for i in [2,1,0]: ## Draw three different scales of grid + + lastd = [None, None] + for i in range(self.grid_depth - 1, -1, -1): dist = br-ul nlTarget = 10.**i + d = 10. ** np.floor(np.log10(abs(dist/nlTarget))+0.5) + for ax in range(0,2): + ts = self.opts['tickSpacing'][ax] + try: + if ts[i] is not None: + d[ax] = ts[i] + except IndexError: + pass + lastd[ax] = d[ax] + ul1 = np.floor(ul / d) * d br1 = np.ceil(br / d) * d dist = br1-ul1 @@ -76,12 +147,25 @@ def generatePicture(self): #print " d", d #print " nl", nl for ax in range(0,2): ## Draw grid for both axes + if i >= len(self.opts['tickSpacing'][ax]): + continue + if d[ax] < lastd[ax]: + continue + ppl = dim[ax] / nl[ax] - c = np.clip(3.*(ppl-3), 0., 30.) - linePen = QtGui.QPen(QtGui.QColor(255, 255, 255, c)) - textPen = QtGui.QPen(QtGui.QColor(255, 255, 255, c*2)) - #linePen.setCosmetic(True) - #linePen.setWidth(1) + c = np.clip(5 * (ppl-3), 0., 50.).astype(int) + + linePen = self.opts['pen'] + lineColor = self.opts['pen'].color() + lineColor.setAlpha(c) + linePen.setColor(lineColor) + + textPen = self.opts['textPen'] + if textPen is not None: + textColor = self.opts['textPen'].color() + textColor.setAlpha(c * 2) + textPen.setColor(textColor) + bx = (ax+1) % 2 for x in range(0, int(nl[ax])): linePen.setCosmetic(False) @@ -102,8 +186,7 @@ def generatePicture(self): if p1[ax] < min(ul[ax], br[ax]) or p1[ax] > max(ul[ax], br[ax]): continue p.drawLine(QtCore.QPointF(p1[0], p1[1]), QtCore.QPointF(p2[0], p2[1])) - if i < 2: - p.setPen(textPen) + if i < 2 and textPen is not None: if ax == 0: x = p1[0] + unit[0] y = ul[1] + unit[1] * 8. @@ -114,7 +197,13 @@ def generatePicture(self): tr = self.deviceTransform() #tr.scale(1.5, 1.5) p.setWorldTransform(fn.invertQTransform(tr)) - for t in texts: - x = tr.map(t[0]) + Point(0.5, 0.5) - p.drawText(x, t[1]) + + if textPen is not None and len(texts) > 0: + # if there is at least one text, then c is set + textColor.setAlpha(c * 2) + p.setPen(QtGui.QPen(textColor)) + for t in texts: + x = tr.map(t[0]) + Point(0.5, 0.5) + p.drawText(x, t[1]) + p.end() diff --git a/pyqtgraph/graphicsItems/HistogramLUTItem.py b/pyqtgraph/graphicsItems/HistogramLUTItem.py index 31764250c8..ad39b60ed6 100644 --- a/pyqtgraph/graphicsItems/HistogramLUTItem.py +++ b/pyqtgraph/graphicsItems/HistogramLUTItem.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ GraphicsWidget displaying an image histogram along with gradient editor. Can be used to adjust the appearance of images. """ @@ -25,25 +26,39 @@ class HistogramLUTItem(GraphicsWidget): """ This is a graphicsWidget which provides controls for adjusting the display of an image. + Includes: - Image histogram - Movable region over histogram to select black/white levels - Gradient editor to define color lookup table for single-channel images + + ================ =========================================================== + image (:class:`~pyqtgraph.ImageItem` or ``None``) If *image* is + provided, then the control will be automatically linked to + the image and changes to the control will be immediately + reflected in the image's appearance. + fillHistogram (bool) By default, the histogram is rendered with a fill. + For performance, set ``fillHistogram=False`` + rgbHistogram (bool) Sets whether the histogram is computed once over all + channels of the image, or once per channel. + levelMode 'mono' or 'rgba'. If 'mono', then only a single set of + black/white level lines is drawn, and the levels apply to + all channels in the image. If 'rgba', then one set of + levels is drawn for each channel. + ================ =========================================================== """ sigLookupTableChanged = QtCore.Signal(object) sigLevelsChanged = QtCore.Signal(object) sigLevelChangeFinished = QtCore.Signal(object) - def __init__(self, image=None, fillHistogram=True): - """ - If *image* (ImageItem) is provided, then the control will be automatically linked to the image and changes to the control will be immediately reflected in the image's appearance. - By default, the histogram is rendered with a fill. For performance, set *fillHistogram* = False. - """ + def __init__(self, image=None, fillHistogram=True, rgbHistogram=False, levelMode='mono'): GraphicsWidget.__init__(self) self.lut = None self.imageItem = lambda: None # fake a dead weakref + self.levelMode = levelMode + self.rgbHistogram = rgbHistogram self.layout = QtGui.QGraphicsGridLayout() self.setLayout(self.layout) @@ -56,9 +71,26 @@ def __init__(self, image=None, fillHistogram=True): self.gradient = GradientEditorItem() self.gradient.setOrientation('right') self.gradient.loadPreset('grey') - self.region = LinearRegionItem([0, 1], LinearRegionItem.Horizontal) - self.region.setZValue(1000) - self.vb.addItem(self.region) + self.regions = [ + LinearRegionItem([0, 1], 'horizontal', swapMode='block'), + LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='r', + brush=fn.mkBrush((255, 50, 50, 50)), span=(0., 1/3.)), + LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='g', + brush=fn.mkBrush((50, 255, 50, 50)), span=(1/3., 2/3.)), + LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='b', + brush=fn.mkBrush((50, 50, 255, 80)), span=(2/3., 1.)), + LinearRegionItem([0, 1], 'horizontal', swapMode='block', pen='w', + brush=fn.mkBrush((255, 255, 255, 50)), span=(2/3., 1.))] + for region in self.regions: + region.setZValue(1000) + self.vb.addItem(region) + region.lines[0].addMarker('<|', 0.5) + region.lines[1].addMarker('|>', 0.5) + region.sigRegionChanged.connect(self.regionChanging) + region.sigRegionChangeFinished.connect(self.regionChanged) + + self.region = self.regions[0] # for backward compatibility. + self.axis = AxisItem('left', linkView=self.vb, maxTickLength=-10, parent=self) self.layout.addItem(self.axis, 0, 0) self.layout.addItem(self.vb, 0, 1) @@ -67,76 +99,64 @@ def __init__(self, image=None, fillHistogram=True): self.gradient.setFlag(self.gradient.ItemStacksBehindParent) self.vb.setFlag(self.gradient.ItemStacksBehindParent) - #self.grid = GridItem() - #self.vb.addItem(self.grid) - self.gradient.sigGradientChanged.connect(self.gradientChanged) - self.region.sigRegionChanged.connect(self.regionChanging) - self.region.sigRegionChangeFinished.connect(self.regionChanged) self.vb.sigRangeChanged.connect(self.viewRangeChanged) - self.plot = PlotDataItem() - self.plot.rotate(90) + add = QtGui.QPainter.CompositionMode_Plus + self.plots = [ + PlotCurveItem(pen=(200, 200, 200, 100)), # mono + PlotCurveItem(pen=(255, 0, 0, 100), compositionMode=add), # r + PlotCurveItem(pen=(0, 255, 0, 100), compositionMode=add), # g + PlotCurveItem(pen=(0, 0, 255, 100), compositionMode=add), # b + PlotCurveItem(pen=(200, 200, 200, 100), compositionMode=add), # a + ] + + self.plot = self.plots[0] # for backward compatibility. + for plot in self.plots: + plot.rotate(90) + self.vb.addItem(plot) + self.fillHistogram(fillHistogram) + self._showRegions() self.vb.addItem(self.plot) self.autoHistogramRange() if image is not None: self.setImageItem(image) - #self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding) def fillHistogram(self, fill=True, level=0.0, color=(100, 100, 200)): - if fill: - self.plot.setFillLevel(level) - self.plot.setFillBrush(color) - else: - self.plot.setFillLevel(None) - - #def sizeHint(self, *args): - #return QtCore.QSizeF(115, 200) + colors = [color, (255, 0, 0, 50), (0, 255, 0, 50), (0, 0, 255, 50), (255, 255, 255, 50)] + for i,plot in enumerate(self.plots): + if fill: + plot.setFillLevel(level) + plot.setBrush(colors[i]) + else: + plot.setFillLevel(None) def paint(self, p, *args): + if self.levelMode != 'mono': + return + pen = self.region.lines[0].pen rgn = self.getLevels() p1 = self.vb.mapFromViewToItem(self, Point(self.vb.viewRect().center().x(), rgn[0])) p2 = self.vb.mapFromViewToItem(self, Point(self.vb.viewRect().center().x(), rgn[1])) gradRect = self.gradient.mapRectToParent(self.gradient.gradRect.rect()) - for pen in [fn.mkPen('k', width=3), pen]: + for pen in [fn.mkPen((0, 0, 0, 100), width=3), pen]: p.setPen(pen) - p.drawLine(p1, gradRect.bottomLeft()) - p.drawLine(p2, gradRect.topLeft()) + p.drawLine(p1 + Point(0, 5), gradRect.bottomLeft()) + p.drawLine(p2 - Point(0, 5), gradRect.topLeft()) p.drawLine(gradRect.topLeft(), gradRect.topRight()) p.drawLine(gradRect.bottomLeft(), gradRect.bottomRight()) - #p.drawRect(self.boundingRect()) - def setHistogramRange(self, mn, mx, padding=0.1): """Set the Y range on the histogram plot. This disables auto-scaling.""" self.vb.enableAutoRange(self.vb.YAxis, False) self.vb.setYRange(mn, mx, padding) - #d = mx-mn - #mn -= d*padding - #mx += d*padding - #self.range = [mn,mx] - #self.updateRange() - #self.vb.setMouseEnabled(False, True) - #self.region.setBounds([mn,mx]) - def autoHistogramRange(self): """Enable auto-scaling on the histogram plot.""" self.vb.enableAutoRange(self.vb.XYAxes) - #self.range = None - #self.updateRange() - #self.vb.setMouseEnabled(False, False) - - #def updateRange(self): - #self.vb.autoRange() - #if self.range is not None: - #self.vb.setYRange(*self.range) - #vr = self.vb.viewRect() - - #self.region.setBounds([vr.top(), vr.bottom()]) def setImageItem(self, img): """Set an ImageItem to have its levels and LUT automatically controlled @@ -145,10 +165,8 @@ def setImageItem(self, img): self.imageItem = weakref.ref(img) img.sigImageChanged.connect(self.imageChanged) img.setLookupTable(self.getLookupTable) ## send function pointer, not the result - #self.gradientChanged() self.regionChanged() self.imageChanged(autoLevel=True) - #self.vb.autoRange() def viewRangeChanged(self): self.update() @@ -161,14 +179,14 @@ def gradientChanged(self): self.imageItem().setLookupTable(self.getLookupTable) ## send function pointer, not the result self.lut = None - #if self.imageItem is not None: - #self.imageItem.setLookupTable(self.gradient.getLookupTable(512)) self.sigLookupTableChanged.emit(self) def getLookupTable(self, img=None, n=None, alpha=None): """Return a lookup table from the color gradient defined by this HistogramLUTItem. """ + if self.levelMode != 'mono': + return None if n is None: if img.dtype == np.uint8: n = 256 @@ -180,36 +198,148 @@ def getLookupTable(self, img=None, n=None, alpha=None): def regionChanged(self): if self.imageItem() is not None: - self.imageItem().setLevels(self.region.getRegion()) + self.imageItem().setLevels(self.getLevels()) self.sigLevelChangeFinished.emit(self) - #self.update() def regionChanging(self): if self.imageItem() is not None: - self.imageItem().setLevels(self.region.getRegion()) - self.sigLevelsChanged.emit(self) + self.imageItem().setLevels(self.getLevels()) self.update() + self.sigLevelsChanged.emit(self) def imageChanged(self, autoLevel=False, autoRange=False): - profiler = debug.Profiler() - h = self.imageItem().getHistogram() - profiler('get histogram') - if h[0] is None: + if self.imageItem() is None: return - self.plot.setData(*h) - profiler('set plot') - if autoLevel: - mn = h[0][0] - mx = h[0][-1] - self.region.setRegion([mn, mx]) - profiler('set region') + + if self.levelMode == 'mono': + for plt in self.plots[1:]: + plt.setVisible(False) + self.plots[0].setVisible(True) + # plot one histogram for all image data + profiler = debug.Profiler() + h = self.imageItem().getHistogram() + profiler('get histogram') + if h[0] is None: + return + self.plot.setData(*h) + profiler('set plot') + if autoLevel: + mn = h[0][0] + mx = h[0][-1] + self.region.setRegion([mn, mx]) + profiler('set region') + else: + mn, mx = self.imageItem().levels + self.region.setRegion([mn, mx]) + else: + # plot one histogram for each channel + self.plots[0].setVisible(False) + ch = self.imageItem().getHistogram(perChannel=True) + if ch[0] is None: + return + for i in range(1, 5): + if len(ch) >= i: + h = ch[i-1] + self.plots[i].setVisible(True) + self.plots[i].setData(*h) + if autoLevel: + mn = h[0][0] + mx = h[0][-1] + self.region[i].setRegion([mn, mx]) + else: + # hide channels not present in image data + self.plots[i].setVisible(False) + # make sure we are displaying the correct number of channels + self._showRegions() def getLevels(self): """Return the min and max levels. + + For rgba mode, this returns a list of the levels for each channel. """ - return self.region.getRegion() + if self.levelMode == 'mono': + return self.region.getRegion() + else: + nch = self.imageItem().channels() + if nch is None: + nch = 3 + return [r.getRegion() for r in self.regions[1:nch+1]] + + def setLevels(self, min=None, max=None, rgba=None): + """Set the min/max (bright and dark) levels. - def setLevels(self, mn, mx): - """Set the min and max levels. + Arguments may be *min* and *max* for single-channel data, or + *rgba* = [(rmin, rmax), ...] for multi-channel data. """ - self.region.setRegion([mn, mx]) + if self.levelMode == 'mono': + if min is None: + min, max = rgba[0] + assert None not in (min, max) + self.region.setRegion((min, max)) + else: + if rgba is None: + raise TypeError("Must specify rgba argument when levelMode != 'mono'.") + for i, levels in enumerate(rgba): + self.regions[i+1].setRegion(levels) + + def setLevelMode(self, mode): + """ Set the method of controlling the image levels offered to the user. + Options are 'mono' or 'rgba'. + """ + assert mode in ('mono', 'rgba') + + if mode == self.levelMode: + return + + oldLevels = self.getLevels() + self.levelMode = mode + self._showRegions() + + # do our best to preserve old levels + if mode == 'mono': + levels = np.array(oldLevels).mean(axis=0) + self.setLevels(*levels) + else: + levels = [oldLevels] * 4 + self.setLevels(rgba=levels) + + # force this because calling self.setLevels might not set the imageItem + # levels if there was no change to the region item + self.imageItem().setLevels(self.getLevels()) + + self.imageChanged() + self.update() + + def _showRegions(self): + for i in range(len(self.regions)): + self.regions[i].setVisible(False) + + if self.levelMode == 'rgba': + imax = 4 + if self.imageItem() is not None: + # Only show rgb channels if connected image lacks alpha. + nch = self.imageItem().channels() + if nch is None: + nch = 3 + xdif = 1.0 / nch + for i in range(1, nch+1): + self.regions[i].setVisible(True) + self.regions[i].setSpan((i-1) * xdif, i * xdif) + self.gradient.hide() + elif self.levelMode == 'mono': + self.regions[0].setVisible(True) + self.gradient.show() + else: + raise ValueError("Unknown level mode %r" % self.levelMode) + + def saveState(self): + return { + 'gradient': self.gradient.saveState(), + 'levels': self.getLevels(), + 'mode': self.levelMode, + } + + def restoreState(self, state): + self.setLevelMode(state['mode']) + self.gradient.restoreState(state['gradient']) + self.setLevels(*state['levels']) diff --git a/pyqtgraph/graphicsItems/ImageItem.py b/pyqtgraph/graphicsItems/ImageItem.py index 3d45ad77db..4b3a94cc00 100644 --- a/pyqtgraph/graphicsItems/ImageItem.py +++ b/pyqtgraph/graphicsItems/ImageItem.py @@ -2,13 +2,17 @@ from ..Qt import QtGui, QtCore import numpy as np -import collections from .. import functions as fn from .. import debug as debug from .GraphicsObject import GraphicsObject from ..Point import Point from .. import getConfigOption +try: + from collections.abc import Callable +except ImportError: + # fallback for python < 3.3 + from collections import Callable __all__ = ['ImageItem'] @@ -16,23 +20,23 @@ class ImageItem(GraphicsObject): """ **Bases:** :class:`GraphicsObject ` - + GraphicsObject displaying an image. Optimized for rapid update (ie video display). This item displays either a 2D numpy array (height, width) or - a 3D array (height, width, RGBa). This array is optionally scaled (see + a 3D array (height, width, RGBa). This array is optionally scaled (see :func:`setLevels `) and/or colored with a lookup table (see :func:`setLookupTable `) before being displayed. - - ImageItem is frequently used in conjunction with - :class:`HistogramLUTItem ` or + + ImageItem is frequently used in conjunction with + :class:`HistogramLUTItem ` or :class:`HistogramLUTWidget ` to provide a GUI for controlling the levels and lookup table used to display the image. """ - + sigImageChanged = QtCore.Signal() sigRemoveRequested = QtCore.Signal(object) # self; emitted when 'remove' is selected from context menu - + def __init__(self, image=None, **kargs): """ See :func:`setImage ` for all allowed initialization arguments. @@ -41,23 +45,24 @@ def __init__(self, image=None, **kargs): self.menu = None self.image = None ## original image data self.qimage = None ## rendered image for display - + self.paintMode = None - + self.levels = None ## [min, max] or [[redMin, redMax], ...] self.lut = None self.autoDownsample = False - + self._lastDownsample = (1, 1) + self.axisOrder = getConfigOption('imageAxisOrder') - + # In some cases, we use a modified lookup table to handle both rescaling # and LUT more efficiently self._effectiveLut = None - + self.drawKernel = None self.border = None self.removable = False - + if image is not None: self.setImage(image, **kargs) else: @@ -66,38 +71,43 @@ def __init__(self, image=None, **kargs): def setCompositionMode(self, mode): """Change the composition mode of the item (see QPainter::CompositionMode in the Qt documentation). This is useful when overlaying multiple ImageItems. - + ============================================ ============================================================ **Most common arguments:** QtGui.QPainter.CompositionMode_SourceOver Default; image replaces the background if it is opaque. Otherwise, it uses the alpha channel to blend the image with the background. - QtGui.QPainter.CompositionMode_Overlay The image color is mixed with the background color to + QtGui.QPainter.CompositionMode_Overlay The image color is mixed with the background color to reflect the lightness or darkness of the background. - QtGui.QPainter.CompositionMode_Plus Both the alpha and color of the image and background pixels + QtGui.QPainter.CompositionMode_Plus Both the alpha and color of the image and background pixels are added together. QtGui.QPainter.CompositionMode_Multiply The output is the image color multiplied by the background. ============================================ ============================================================ """ self.paintMode = mode self.update() - + def setBorder(self, b): self.border = fn.mkPen(b) self.update() - + def width(self): if self.image is None: return None axis = 0 if self.axisOrder == 'col-major' else 1 return self.image.shape[axis] - + def height(self): if self.image is None: return None axis = 1 if self.axisOrder == 'col-major' else 0 return self.image.shape[axis] + def channels(self): + if self.image is None: + return None + return self.image.shape[2] if self.image.ndim == 3 else 1 + def boundingRect(self): if self.image is None: return QtCore.QRectF(0., 0., 0., 0.) @@ -106,10 +116,10 @@ def boundingRect(self): def setLevels(self, levels, update=True): """ Set image scaling levels. Can be one of: - + * [blackLevel, whiteLevel] * [[minRed, maxRed], [minGreen, maxGreen], [minBlue, maxBlue]] - + Only the first format is compatible with lookup tables. See :func:`makeARGB ` for more details on how levels are applied. """ @@ -120,18 +130,18 @@ def setLevels(self, levels, update=True): self._effectiveLut = None if update: self.updateImage() - + def getLevels(self): return self.levels #return self.whiteLevel, self.blackLevel def setLookupTable(self, lut, update=True): """ - Set the lookup table (numpy array) to use for this image. (see + Set the lookup table (numpy array) to use for this image. (see :func:`makeARGB ` for more information on how this is used). - Optionally, lut can be a callable that accepts the current image as an + Optionally, lut can be a callable that accepts the current image as an argument and returns the lookup table to use. - + Ordinarily, this table is supplied by a :class:`HistogramLUTItem ` or :class:`GradientEditorItem `. """ @@ -144,7 +154,7 @@ def setLookupTable(self, lut, update=True): def setAutoDownsample(self, ads): """ Set the automatic downsampling mode for this ImageItem. - + Added in version 0.9.9 """ self.autoDownsample = ads @@ -193,43 +203,44 @@ def setImage(self, image=None, autoLevels=None, **kargs): """ Update the image displayed by this item. For more information on how the image is processed before displaying, see :func:`makeARGB ` - + ================= ========================================================================= **Arguments:** - image (numpy array) Specifies the image data. May be 2D (width, height) or + image (numpy array) Specifies the image data. May be 2D (width, height) or 3D (width, height, RGBa). The array dtype must be integer or floating point of any bit depth. For 3D arrays, the third dimension must be of length 3 (RGB) or 4 (RGBA). See *notes* below. - autoLevels (bool) If True, this forces the image to automatically select + autoLevels (bool) If True, this forces the image to automatically select levels based on the maximum and minimum values in the data. By default, this argument is true unless the levels argument is given. lut (numpy array) The color lookup table to use when displaying the image. See :func:`setLookupTable `. levels (min, max) The minimum and maximum values to use when rescaling the image - data. By default, this will be set to the minimum and maximum values + data. By default, this will be set to the minimum and maximum values in the image. If the image array has dtype uint8, no rescaling is necessary. opacity (float 0.0-1.0) compositionMode See :func:`setCompositionMode ` border Sets the pen used when drawing the image border. Default is None. autoDownsample (bool) If True, the image is automatically downsampled to match the - screen resolution. This improves performance for large images and - reduces aliasing. + screen resolution. This improves performance for large images and + reduces aliasing. If autoDownsample is not specified, then ImageItem will + choose whether to downsample the image based on its size. ================= ========================================================================= - - - **Notes:** - + + + **Notes:** + For backward compatibility, image data is assumed to be in column-major order (column, row). However, most image data is stored in row-major order (row, column) and will need to be transposed before calling setImage():: - + imageitem.setImage(imagedata.T) - + This requirement can be changed by calling ``image.setOpts(axisOrder='row-major')`` or by changing the ``imageAxisOrder`` :ref:`global configuration option `. - - + + """ profile = debug.Profiler() @@ -262,8 +273,9 @@ def setImage(self, image=None, autoLevels=None, **kargs): img = self.image while img.size > 2**16: img = img[::2, ::2] - mn, mx = img.min(), img.max() - if mn == mx: + mn, mx = np.nanmin(img), np.nanmax(img) + # mn and mx can still be NaN if the data is all-NaN + if mn == mx or np.isnan(mn) or np.isnan(mx): mn = 0 mx = 255 kargs['levels'] = [mn,mx] @@ -285,7 +297,7 @@ def setImage(self, image=None, autoLevels=None, **kargs): def dataTransform(self): """Return the transform that maps from this image's input array to its local coordinate system. - + This transform corrects for the transposition that occurs when image data is interpreted in row-major order. """ @@ -300,7 +312,7 @@ def dataTransform(self): def inverseDataTransform(self): """Return the transform that maps from this image's local coordinate system to its input array. - + See dataTransform() for more information. """ tr = QtGui.QTransform() @@ -328,11 +340,11 @@ def quickMinMax(self, targetSize=1e6): sl = [slice(None)] * data.ndim sl[ax] = slice(None, None, 2) data = data[sl] - return nanmin(data), nanmax(data) + return np.nanmin(data), np.nanmax(data) def updateImage(self, *args, **kargs): ## used for re-rendering qimage from self.image. - + ## can we make any assumptions here that speed things up? ## dtype, range, size are all the same? defaults = { @@ -343,20 +355,30 @@ def updateImage(self, *args, **kargs): def render(self): # Convert data to QImage for display. - + profile = debug.Profiler() if self.image is None or self.image.size == 0: return - if isinstance(self.lut, collections.Callable): - lut = self.lut(self.image) + + # Request a lookup table if this image has only one channel + if self.image.ndim == 2 or self.image.shape[2] == 1: + if isinstance(self.lut, Callable): + lut = self.lut(self.image) + else: + lut = self.lut else: - lut = self.lut + lut = None if self.autoDownsample: # reduce dimensions of image based on screen resolution o = self.mapToDevice(QtCore.QPointF(0,0)) x = self.mapToDevice(QtCore.QPointF(1,0)) y = self.mapToDevice(QtCore.QPointF(0,1)) + + # Check if graphics view is too small to render anything + if o is None or x is None or y is None: + return + w = Point(x-o).length() h = Point(y-o).length() if w == 0 or h == 0: @@ -368,6 +390,10 @@ def render(self): image = fn.downsample(self.image, xds, axis=axes[0]) image = fn.downsample(image, yds, axis=axes[1]) self._lastDownsample = (xds, yds) + + # Check if downsampling reduced the image size to zero due to inf values. + if image.size == 0: + return else: image = self.image @@ -382,24 +408,27 @@ def render(self): levdiff = maxlev - minlev levdiff = 1 if levdiff == 0 else levdiff # don't allow division by 0 if lut is None: - efflut = fn.rescaleData(ind, scale=255./levdiff, + efflut = fn.rescaleData(ind, scale=255./levdiff, offset=minlev, dtype=np.ubyte) else: lutdtype = np.min_scalar_type(lut.shape[0]-1) efflut = fn.rescaleData(ind, scale=(lut.shape[0]-1)/levdiff, offset=minlev, dtype=lutdtype, clip=(0, lut.shape[0]-1)) efflut = lut[efflut] - + self._effectiveLut = efflut lut = self._effectiveLut levels = None - + + # Convert single-channel image to 2D array + if image.ndim == 3 and image.shape[-1] == 1: + image = image[..., 0] + # Assume images are in column-major order for backward compatibility # (most images are in row-major order) - if self.axisOrder == 'col-major': image = image.transpose((1, 0, 2)[:image.ndim]) - + argb, alpha = fn.makeARGB(image, lut=lut, levels=levels) self.qimage = fn.makeQImage(argb, alpha, transpose=False) @@ -429,49 +458,71 @@ def save(self, fileName, *args): self.render() self.qimage.save(fileName, *args) - def getHistogram(self, bins='auto', step='auto', targetImageSize=200, targetHistogramSize=500, **kwds): + def getHistogram(self, bins='auto', step='auto', perChannel=False, targetImageSize=200, + targetHistogramSize=500, **kwds): """Returns x and y arrays containing the histogram values for the current image. For an explanation of the return format, see numpy.histogram(). - + The *step* argument causes pixels to be skipped when computing the histogram to save time. If *step* is 'auto', then a step is chosen such that the analyzed data has dimensions roughly *targetImageSize* for each axis. - - The *bins* argument and any extra keyword arguments are passed to + + The *bins* argument and any extra keyword arguments are passed to np.histogram(). If *bins* is 'auto', then a bin number is automatically chosen based on the image characteristics: - - * Integer images will have approximately *targetHistogramSize* bins, + + * Integer images will have approximately *targetHistogramSize* bins, with each bin having an integer width. * All other types will have *targetHistogramSize* bins. - + + If *perChannel* is True, then the histogram is computed once per channel + and the output is a list of the results. + This method is also used when automatically computing levels. """ - if self.image is None: - return None,None + if self.image is None or self.image.size == 0: + return None, None if step == 'auto': - step = (int(np.ceil(self.image.shape[0] / targetImageSize)), - int(np.ceil(self.image.shape[1] / targetImageSize))) + step = (max(1, int(np.ceil(self.image.shape[0] / targetImageSize))), + max(1, int(np.ceil(self.image.shape[1] / targetImageSize)))) if np.isscalar(step): step = (step, step) stepData = self.image[::step[0], ::step[1]] - - if bins == 'auto': + + if isinstance(bins, str) and bins == 'auto': + mn = np.nanmin(stepData) + mx = np.nanmax(stepData) + if mx == mn: + # degenerate image, arange will fail + mx += 1 + if np.isnan(mn) or np.isnan(mx): + # the data are all-nan + return None, None if stepData.dtype.kind in "ui": - mn = stepData.min() - mx = stepData.max() + # For integer data, we select the bins carefully to avoid aliasing step = np.ceil((mx-mn) / 500.) bins = np.arange(mn, mx+1.01*step, step, dtype=np.int) - if len(bins) == 0: - bins = [mn, mx] else: - bins = 500 + # for float data, let numpy select the bins. + bins = np.linspace(mn, mx, 500) + + if len(bins) == 0: + bins = [mn, mx] kwds['bins'] = bins - stepData = stepData[np.isfinite(stepData)] - hist = np.histogram(stepData, **kwds) - - return hist[1][:-1], hist[0] + + if perChannel: + hist = [] + for i in range(stepData.shape[-1]): + stepChan = stepData[..., i] + stepChan = stepChan[np.isfinite(stepChan)] + h = np.histogram(stepChan, **kwds) + hist.append((h[1][:-1], h[0])) + return hist + else: + stepData = stepData[np.isfinite(stepData)] + hist = np.histogram(stepData, **kwds) + return hist[1][:-1], hist[0] def setPxMode(self, b): """ @@ -481,7 +532,7 @@ def setPxMode(self, b): (see GraphicsItem::ItemIgnoresTransformations in the Qt documentation) """ self.setFlag(self.ItemIgnoresTransformations, b) - + def setScaledMode(self): self.setPxMode(False) @@ -491,18 +542,29 @@ def getPixmap(self): if self.qimage is None: return None return QtGui.QPixmap.fromImage(self.qimage) - + def pixelSize(self): """return scene-size of a single pixel in the image""" br = self.sceneBoundingRect() if self.image is None: return 1,1 return br.width()/self.width(), br.height()/self.height() - + def viewTransformChanged(self): if self.autoDownsample: - self.qimage = None - self.update() + o = self.mapToDevice(QtCore.QPointF(0,0)) + x = self.mapToDevice(QtCore.QPointF(1,0)) + y = self.mapToDevice(QtCore.QPointF(0,1)) + w = Point(x-o).length() + h = Point(y-o).length() + if w == 0 or h == 0: + self.qimage = None + return + xds = max(1, int(1.0 / w)) + yds = max(1, int(1.0 / h)) + if (xds, yds) != self._lastDownsample: + self.qimage = None + self.update() def mouseDragEvent(self, ev): if ev.button() != QtCore.Qt.LeftButton: @@ -539,7 +601,7 @@ def getMenu(self): self.menu.addAction(remAct) self.menu.remAct = remAct return self.menu - + def hoverEvent(self, ev): if not ev.isExit() and self.drawKernel is not None and ev.acceptDrags(QtCore.Qt.LeftButton): ev.acceptClicks(QtCore.Qt.LeftButton) ## we don't use the click, but we also don't want anyone else to use it. @@ -552,7 +614,7 @@ def tabletEvent(self, ev): #print(ev.device()) #print(ev.pointerType()) #print(ev.pressure()) - + def drawAt(self, pos, ev=None): pos = [int(pos.x()), int(pos.y())] dk = self.drawKernel @@ -561,7 +623,7 @@ def drawAt(self, pos, ev=None): sy = [0,dk.shape[1]] tx = [pos[0] - kc[0], pos[0] - kc[0]+ dk.shape[0]] ty = [pos[1] - kc[1], pos[1] - kc[1]+ dk.shape[1]] - + for i in [0,1]: dx1 = -min(0, tx[i]) dx2 = min(0, self.image.shape[0]-tx[i]) @@ -577,8 +639,8 @@ def drawAt(self, pos, ev=None): ss = (slice(sx[0],sx[1]), slice(sy[0],sy[1])) mask = self.drawMask src = dk - - if isinstance(self.drawMode, collections.Callable): + + if isinstance(self.drawMode, Callable): self.drawMode(dk, self.image, mask, ss, ts, ev) else: src = src[ss] @@ -593,7 +655,7 @@ def drawAt(self, pos, ev=None): else: raise Exception("Unknown draw mode '%s'" % self.drawMode) self.updateImage() - + def setDrawKernel(self, kernel=None, mask=None, center=(0,0), mode='set'): self.drawKernel = kernel self.drawKernelCenter = center diff --git a/pyqtgraph/graphicsItems/InfiniteLine.py b/pyqtgraph/graphicsItems/InfiniteLine.py index 3da8232724..37d84c7ef1 100644 --- a/pyqtgraph/graphicsItems/InfiniteLine.py +++ b/pyqtgraph/graphicsItems/InfiniteLine.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from ..Qt import QtGui, QtCore from ..Point import Point from .GraphicsObject import GraphicsObject @@ -31,7 +32,8 @@ class InfiniteLine(GraphicsObject): sigPositionChanged = QtCore.Signal(object) def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None, - hoverPen=None, label=None, labelOpts=None, name=None): + hoverPen=None, label=None, labelOpts=None, span=(0, 1), markers=None, + name=None): """ =============== ================================================================== **Arguments:** @@ -41,22 +43,28 @@ def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None, pen Pen to use when drawing line. Can be any arguments that are valid for :func:`mkPen `. Default pen is transparent yellow. + hoverPen Pen to use when the mouse cursor hovers over the line. + Only used when movable=True. movable If True, the line can be dragged to a new position by the user. + bounds Optional [min, max] bounding values. Bounds are only valid if the + line is vertical or horizontal. hoverPen Pen to use when drawing line when hovering over it. Can be any arguments that are valid for :func:`mkPen `. Default pen is red. - bounds Optional [min, max] bounding values. Bounds are only valid if the - line is vertical or horizontal. label Text to be displayed in a label attached to the line, or None to show no label (default is None). May optionally include formatting strings to display the line value. labelOpts A dict of keyword arguments to use when constructing the text label. See :class:`InfLineLabel`. + span Optional tuple (min, max) giving the range over the view to draw + the line. For example, with a vertical line, use span=(0.5, 1) + to draw only on the top half of the view. + markers List of (marker, position, size) tuples, one per marker to display + on the line. See the addMarker method. name Name of the item =============== ================================================================== """ self._boundingRect = None - self._line = None self._name = name @@ -79,11 +87,25 @@ def __init__(self, pos=None, angle=90, pen=None, movable=False, bounds=None, if pen is None: pen = (200, 200, 100) self.setPen(pen) + if hoverPen is None: self.setHoverPen(color=(255,0,0), width=self.pen.width()) else: self.setHoverPen(hoverPen) + + self.span = span self.currentPen = self.pen + + self.markers = [] + self._maxMarkerSize = 0 + if markers is not None: + for m in markers: + self.addMarker(*m) + + # Cache variables for managing bounds + self._endPoints = [0, 1] # + self._bounds = None + self._lastViewSize = None if label is not None: labelOpts = {} if labelOpts is None else labelOpts @@ -98,7 +120,12 @@ def setBounds(self, bounds): """Set the (minimum, maximum) allowable values when dragging.""" self.maxRange = bounds self.setValue(self.value()) - + + def bounds(self): + """Return the (minimum, maximum) values allowed when dragging. + """ + return self.maxRange[:] + def setPen(self, *args, **kwargs): """Set the pen for drawing the line. Allowable arguments are any that are valid for :func:`mkPen `.""" @@ -115,11 +142,71 @@ def setHoverPen(self, *args, **kwargs): If the line is not movable, then hovering is also disabled. Added in version 0.9.9.""" + # If user did not supply a width, then copy it from pen + widthSpecified = ((len(args) == 1 and + (isinstance(args[0], QtGui.QPen) or + (isinstance(args[0], dict) and 'width' in args[0])) + ) or 'width' in kwargs) self.hoverPen = fn.mkPen(*args, **kwargs) + if not widthSpecified: + self.hoverPen.setWidth(self.pen.width()) + if self.mouseHovering: self.currentPen = self.hoverPen self.update() + + def addMarker(self, marker, position=0.5, size=10.0): + """Add a marker to be displayed on the line. + + ============= ========================================================= + **Arguments** + marker String indicating the style of marker to add: + ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, + ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` + position Position (0.0-1.0) along the visible extent of the line + to place the marker. Default is 0.5. + size Size of the marker in pixels. Default is 10.0. + ============= ========================================================= + """ + path = QtGui.QPainterPath() + if marker == 'o': + path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) + if '<|' in marker: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + if '|>' in marker: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + if '>|' in marker: + p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)]) + path.addPolygon(p) + path.closeSubpath() + if '|<' in marker: + p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + if '^' in marker: + p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + if 'v' in marker: + p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + self.markers.append((path, position, size)) + self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + self.update() + def clearMarkers(self): + """ Remove all markers from this line. + """ + self.markers = [] + self._maxMarkerSize = 0 + self.update() + def setAngle(self, angle): """ Takes angle argument in degrees. @@ -128,7 +215,7 @@ def setAngle(self, angle): Note that the use of value() and setValue() changes if the line is not vertical or horizontal. """ - self.angle = ((angle+45) % 180) - 45 ## -45 <= angle < 135 + self.angle = angle #((angle+45) % 180) - 45 ## -45 <= angle < 135 self.resetTransform() self.rotate(self.angle) self.update() @@ -199,35 +286,98 @@ def setValue(self, v): #else: #print "ignore", change #return GraphicsObject.itemChange(self, change, val) + + def setSpan(self, mn, mx): + if self.span != (mn, mx): + self.span = (mn, mx) + self.update() def _invalidateCache(self): - self._line = None self._boundingRect = None + def _computeBoundingRect(self): + #br = UIGraphicsItem.boundingRect(self) + vr = self.viewRect() # bounds of containing ViewBox mapped to local coords. + if vr is None: + return QtCore.QRectF() + + ## add a 4-pixel radius around the line for mouse interaction. + + px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line + if px is None: + px = 0 + pw = max(self.pen.width() / 2, self.hoverPen.width() / 2) + w = max(4, self._maxMarkerSize + pw) + 1 + w = w * px + br = QtCore.QRectF(vr) + br.setBottom(-w) + br.setTop(w) + + length = br.width() + left = br.left() + length * self.span[0] + right = br.left() + length * self.span[1] + br.setLeft(left) + br.setRight(right) + br = br.normalized() + + vs = self.getViewBox().size() + + if self._bounds != br or self._lastViewSize != vs: + self._bounds = br + self._lastViewSize = vs + self.prepareGeometryChange() + + self._endPoints = (left, right) + self._lastViewRect = vr + + return self._bounds + def boundingRect(self): if self._boundingRect is None: - #br = UIGraphicsItem.boundingRect(self) - br = self.viewRect() - if br is None: - return QtCore.QRectF() - - ## add a 4-pixel radius around the line for mouse interaction. - px = self.pixelLength(direction=Point(1,0), ortho=True) ## get pixel length orthogonal to the line - if px is None: - px = 0 - w = (max(4, self.pen.width()/2, self.hoverPen.width()/2)+1) * px - br.setBottom(-w) - br.setTop(w) - - br = br.normalized() - self._boundingRect = br - self._line = QtCore.QLineF(br.right(), 0.0, br.left(), 0.0) + self._boundingRect = self._computeBoundingRect() return self._boundingRect def paint(self, p, *args): - p.setPen(self.currentPen) - p.drawLine(self._line) - + p.setRenderHint(p.Antialiasing) + + left, right = self._endPoints + pen = self.currentPen + pen.setJoinStyle(QtCore.Qt.MiterJoin) + p.setPen(pen) + p.drawLine(Point(left, 0), Point(right, 0)) + + + if len(self.markers) == 0: + return + + # paint markers in native coordinate system + tr = p.transform() + p.resetTransform() + + start = tr.map(Point(left, 0)) + end = tr.map(Point(right, 0)) + up = tr.map(Point(left, 1)) + dif = end - start + length = Point(dif).length() + angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi + + p.translate(start) + p.rotate(angle) + + up = up - start + det = up.x() * dif.y() - dif.x() * up.y() + p.scale(1, 1 if det > 0 else -1) + + p.setBrush(fn.mkBrush(self.currentPen.color())) + #p.setPen(fn.mkPen(None)) + tr = p.transform() + for path, pos, size in self.markers: + p.setTransform(tr) + x = length * pos + p.translate(x, 0) + p.scale(size, size) + p.drawPath(path) + def dataBounds(self, axis, frac=1.0, orthoRange=None): if axis == 0: return None ## x axis should never be auto-scaled diff --git a/pyqtgraph/graphicsItems/LegendItem.py b/pyqtgraph/graphicsItems/LegendItem.py index 20d6416e8f..da830782ea 100644 --- a/pyqtgraph/graphicsItems/LegendItem.py +++ b/pyqtgraph/graphicsItems/LegendItem.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from .GraphicsWidget import GraphicsWidget from .LabelItem import LabelItem from ..Qt import QtGui, QtCore @@ -8,167 +9,246 @@ from .GraphicsWidgetAnchor import GraphicsWidgetAnchor __all__ = ['LegendItem'] + class LegendItem(GraphicsWidget, GraphicsWidgetAnchor): """ Displays a legend used for describing the contents of a plot. - LegendItems are most commonly created by calling PlotItem.addLegend(). - Note that this item should not be added directly to a PlotItem. Instead, - Make it a direct descendant of the PlotItem:: + LegendItems are most commonly created by calling :meth:`PlotItem.addLegend + `. + + Note that this item should *not* be added directly to a PlotItem (via + :meth:`PlotItem.addItem `). Instead, make it a + direct descendant of the PlotItem:: legend.setParentItem(plotItem) """ - def __init__(self, size=None, offset=None): + def __init__(self, size=None, offset=None, horSpacing=25, verSpacing=0, pen=None, + brush=None, labelTextColor=None, **kwargs): """ ============== =============================================================== **Arguments:** size Specifies the fixed size (width, height) of the legend. If - this argument is omitted, the legend will autimatically resize + this argument is omitted, the legend will automatically resize to fit its contents. offset Specifies the offset position relative to the legend's parent. Positive values offset from the left or top; negative values offset from the right or bottom. If offset is None, the legend must be anchored manually by calling anchor() or positioned by calling setPos(). + horSpacing Specifies the spacing between the line symbol and the label. + verSpacing Specifies the spacing between individual entries of the legend + vertically. (Can also be negative to have them really close) + pen Pen to use when drawing legend border. Any single argument + accepted by :func:`mkPen ` is allowed. + brush QBrush to use as legend background filling. Any single argument + accepted by :func:`mkBrush ` is allowed. + labelTextColor Pen to use when drawing legend text. Any single argument + accepted by :func:`mkPen ` is allowed. ============== =============================================================== - + """ - - GraphicsWidget.__init__(self) GraphicsWidgetAnchor.__init__(self) self.setFlag(self.ItemIgnoresTransformations) self.layout = QtGui.QGraphicsGridLayout() + self.layout.setVerticalSpacing(verSpacing) + self.layout.setHorizontalSpacing(horSpacing) + self.setLayout(self.layout) self.items = [] self.size = size - self.offset = offset if size is not None: self.setGeometry(QtCore.QRectF(0, 0, self.size[0], self.size[1])) - + + self.opts = { + 'pen': fn.mkPen(pen), + 'brush': fn.mkBrush(brush), + 'labelTextColor': labelTextColor, + 'offset': offset, + } + + self.opts.update(kwargs) + + def offset(self): + """Get the offset position relative to the parent.""" + return self.opts['offset'] + + def setOffset(self, offset): + """Set the offset position relative to the parent.""" + self.opts['offset'] = offset + + offset = Point(self.opts['offset']) + anchorx = 1 if offset[0] <= 0 else 0 + anchory = 1 if offset[1] <= 0 else 0 + anchor = (anchorx, anchory) + self.anchor(itemPos=anchor, parentPos=anchor, offset=offset) + + def pen(self): + """Get the QPen used to draw the border around the legend.""" + return self.opts['pen'] + + def setPen(self, *args, **kargs): + """Set the pen used to draw a border around the legend. + + Accepts the same arguments as :func:`~pyqtgraph.mkPen`. + """ + pen = fn.mkPen(*args, **kargs) + self.opts['pen'] = pen + + self.update() + + def brush(self): + """Get the QBrush used to draw the legend background.""" + return self.opts['brush'] + + def setBrush(self, *args, **kargs): + """Set the brush used to draw the legend background. + + Accepts the same arguments as :func:`~pyqtgraph.mkBrush`. + """ + brush = fn.mkBrush(*args, **kargs) + if self.opts['brush'] == brush: + return + self.opts['brush'] = brush + + self.update() + + def labelTextColor(self): + """Get the QColor used for the item labels.""" + return self.opts['labelTextColor'] + + def setLabelTextColor(self, *args, **kargs): + """Set the color of the item labels. + + Accepts the same arguments as :func:`~pyqtgraph.mkColor`. + """ + self.opts['labelTextColor'] = fn.mkColor(*args, **kargs) + for sample, label in self.items: + label.setAttr('color', self.opts['labelTextColor']) + + self.update() + def setParentItem(self, p): + """Set the parent.""" ret = GraphicsWidget.setParentItem(self, p) - if self.offset is not None: - offset = Point(self.offset) + if self.opts['offset'] is not None: + offset = Point(self.opts['offset']) anchorx = 1 if offset[0] <= 0 else 0 anchory = 1 if offset[1] <= 0 else 0 anchor = (anchorx, anchory) self.anchor(itemPos=anchor, parentPos=anchor, offset=offset) return ret - + def addItem(self, item, name): """ - Add a new entry to the legend. + Add a new entry to the legend. ============== ======================================================== **Arguments:** - item A PlotDataItem from which the line and point style - of the item will be determined or an instance of - ItemSample (or a subclass), allowing the item display - to be customized. + item A :class:`~pyqtgraph.PlotDataItem` from which the line + and point style of the item will be determined or an + instance of ItemSample (or a subclass), allowing the + item display to be customized. title The title to display for this item. Simple HTML allowed. ============== ======================================================== """ - label = LabelItem(name) + label = LabelItem(name, color=self.opts['labelTextColor'], justify='left') if isinstance(item, ItemSample): sample = item else: - sample = ItemSample(item) + sample = ItemSample(item) + row = self.layout.rowCount() self.items.append((sample, label)) self.layout.addItem(sample, row, 0) self.layout.addItem(label, row, 1) self.updateSize() - - def removeItem(self, name): + + def removeItem(self, item): """ - Removes one item from the legend. + Removes one item from the legend. ============== ======================================================== **Arguments:** - title The title displayed for this item. + item The item to remove or its name. ============== ======================================================== """ - # Thanks, Ulrich! - # cycle for a match for sample, label in self.items: - if label.text == name: # hit - self.items.remove( (sample, label) ) # remove from itemlist + if sample.item is item or label.text == item: + self.items.remove((sample, label)) # remove from itemlist self.layout.removeItem(sample) # remove from layout sample.close() # remove from drawing self.layout.removeItem(label) label.close() self.updateSize() # redraq box + return # return after first match + + def clear(self): + """Remove all items from the legend.""" + for sample, label in self.items: + self.layout.removeItem(sample) + self.layout.removeItem(label) + + self.items = [] + self.updateSize() def updateSize(self): if self.size is not None: return - - height = 0 - width = 0 - #print("-------") - for sample, label in self.items: - height += max(sample.height(), label.height()) + 3 - width = max(width, sample.width()+label.width()) - #print(width, height) - #print width, height - self.setGeometry(0, 0, width+25, height) - + + self.setGeometry(0, 0, 0, 0) + def boundingRect(self): return QtCore.QRectF(0, 0, self.width(), self.height()) - + def paint(self, p, *args): - p.setPen(fn.mkPen(255,255,255,100)) - p.setBrush(fn.mkBrush(100,100,100,50)) + p.setPen(self.opts['pen']) + p.setBrush(self.opts['brush']) p.drawRect(self.boundingRect()) def hoverEvent(self, ev): ev.acceptDrags(QtCore.Qt.LeftButton) - + def mouseDragEvent(self, ev): if ev.button() == QtCore.Qt.LeftButton: + ev.accept() dpos = ev.pos() - ev.lastPos() self.autoAnchor(self.pos() + dpos) - + + class ItemSample(GraphicsWidget): """ Class responsible for drawing a single item in a LegendItem (sans label). - + This may be subclassed to draw custom graphics in a Legend. """ ## Todo: make this more generic; let each item decide how it should be represented. def __init__(self, item): GraphicsWidget.__init__(self) self.item = item - + def boundingRect(self): return QtCore.QRectF(0, 0, 20, 20) - + def paint(self, p, *args): - #p.setRenderHint(p.Antialiasing) # only if the data is antialiased. opts = self.item.opts - - if opts.get('fillLevel',None) is not None and opts.get('fillBrush',None) is not None: - p.setBrush(fn.mkBrush(opts['fillBrush'])) - p.setPen(fn.mkPen(None)) - p.drawPolygon(QtGui.QPolygonF([QtCore.QPointF(2,18), QtCore.QPointF(18,2), QtCore.QPointF(18,18)])) - + + if opts.get('antialias'): + p.setRenderHint(p.Antialiasing) + if not isinstance(self.item, ScatterPlotItem): p.setPen(fn.mkPen(opts['pen'])) - p.drawLine(2, 18, 18, 2) - + p.drawLine(0, 11, 20, 11) + symbol = opts.get('symbol', None) if symbol is not None: if isinstance(self.item, PlotDataItem): opts = self.item.scatter.opts - + pen = fn.mkPen(opts['pen']) brush = fn.mkBrush(opts['brush']) size = opts['size'] - - p.translate(10,10) + + p.translate(10, 10) path = drawSymbol(p, symbol, size, pen, brush) - - - - diff --git a/pyqtgraph/graphicsItems/LinearRegionItem.py b/pyqtgraph/graphicsItems/LinearRegionItem.py index e139190bc4..56ff57488b 100644 --- a/pyqtgraph/graphicsItems/LinearRegionItem.py +++ b/pyqtgraph/graphicsItems/LinearRegionItem.py @@ -1,14 +1,15 @@ +# -*- coding: utf-8 -*- from ..Qt import QtGui, QtCore -from .UIGraphicsItem import UIGraphicsItem +from .GraphicsObject import GraphicsObject from .InfiniteLine import InfiniteLine from .. import functions as fn from .. import debug as debug __all__ = ['LinearRegionItem'] -class LinearRegionItem(UIGraphicsItem): +class LinearRegionItem(GraphicsObject): """ - **Bases:** :class:`UIGraphicsItem ` + **Bases:** :class:`GraphicsObject ` Used for marking a horizontal or vertical region in plots. The region can be dragged and is bounded by lines which can be dragged individually. @@ -26,65 +27,110 @@ class LinearRegionItem(UIGraphicsItem): sigRegionChanged = QtCore.Signal(object) Vertical = 0 Horizontal = 1 + _orientation_axis = { + Vertical: 0, + Horizontal: 1, + 'vertical': 0, + 'horizontal': 1, + } - def __init__(self, values=[0,1], orientation=None, brush=None, movable=True, bounds=None): + def __init__(self, values=(0, 1), orientation='vertical', brush=None, pen=None, + hoverBrush=None, hoverPen=None, movable=True, bounds=None, + span=(0, 1), swapMode='sort'): """Create a new LinearRegionItem. ============== ===================================================================== **Arguments:** values A list of the positions of the lines in the region. These are not limits; limits can be set by specifying bounds. - orientation Options are LinearRegionItem.Vertical or LinearRegionItem.Horizontal. - If not specified it will be vertical. + orientation Options are 'vertical' or 'horizontal', indicating the + The default is 'vertical', indicating that the brush Defines the brush that fills the region. Can be any arguments that are valid for :func:`mkBrush `. Default is transparent blue. + pen The pen to use when drawing the lines that bound the region. + hoverBrush The brush to use when the mouse is hovering over the region. + hoverPen The pen to use when the mouse is hovering over the region. movable If True, the region and individual lines are movable by the user; if False, they are static. bounds Optional [min, max] bounding values for the region + span Optional [min, max] giving the range over the view to draw + the region. For example, with a vertical line, use + ``span=(0.5, 1)`` to draw only on the top half of the + view. + swapMode Sets the behavior of the region when the lines are moved such that + their order reverses. "block" means the user cannot drag + one line past the other. "push" causes both lines to be + moved if one would cross the other. "sort" means that + lines may trade places, but the output of getRegion + always gives the line positions in ascending order. None + means that no attempt is made to handle swapped line + positions. The default is "sort". ============== ===================================================================== """ - UIGraphicsItem.__init__(self) - if orientation is None: - orientation = LinearRegionItem.Vertical + GraphicsObject.__init__(self) self.orientation = orientation self.bounds = QtCore.QRectF() self.blockLineSignal = False self.moving = False self.mouseHovering = False + self.span = span + self.swapMode = swapMode + self._bounds = None - if orientation == LinearRegionItem.Horizontal: + # note LinearRegionItem.Horizontal and LinearRegionItem.Vertical + # are kept for backward compatibility. + lineKwds = dict( + movable=movable, + bounds=bounds, + span=span, + pen=pen, + hoverPen=hoverPen, + ) + + if orientation in ('horizontal', LinearRegionItem.Horizontal): self.lines = [ - InfiniteLine(QtCore.QPointF(0, values[0]), 0, movable=movable, bounds=bounds), - InfiniteLine(QtCore.QPointF(0, values[1]), 0, movable=movable, bounds=bounds)] - elif orientation == LinearRegionItem.Vertical: + # rotate lines to 180 to preserve expected line orientation + # with respect to region. This ensures that placing a '<|' + # marker on lines[0] causes it to point left in vertical mode + # and down in horizontal mode. + InfiniteLine(QtCore.QPointF(0, values[0]), angle=0, **lineKwds), + InfiniteLine(QtCore.QPointF(0, values[1]), angle=0, **lineKwds)] + self.lines[0].scale(1, -1) + self.lines[1].scale(1, -1) + elif orientation in ('vertical', LinearRegionItem.Vertical): self.lines = [ - InfiniteLine(QtCore.QPointF(values[1], 0), 90, movable=movable, bounds=bounds), - InfiniteLine(QtCore.QPointF(values[0], 0), 90, movable=movable, bounds=bounds)] + InfiniteLine(QtCore.QPointF(values[0], 0), angle=90, **lineKwds), + InfiniteLine(QtCore.QPointF(values[1], 0), angle=90, **lineKwds)] else: - raise Exception('Orientation must be one of LinearRegionItem.Vertical or LinearRegionItem.Horizontal') - + raise Exception("Orientation must be 'vertical' or 'horizontal'.") for l in self.lines: l.setParentItem(self) l.sigPositionChangeFinished.connect(self.lineMoveFinished) - l.sigPositionChanged.connect(self.lineMoved) + self.lines[0].sigPositionChanged.connect(lambda: self.lineMoved(0)) + self.lines[1].sigPositionChanged.connect(lambda: self.lineMoved(1)) if brush is None: brush = QtGui.QBrush(QtGui.QColor(0, 0, 255, 50)) self.setBrush(brush) + if hoverBrush is None: + c = self.brush.color() + c.setAlpha(min(c.alpha() * 2, 255)) + hoverBrush = fn.mkBrush(c) + self.setHoverBrush(hoverBrush) + self.setMovable(movable) def getRegion(self): """Return the values at the edges of the region.""" - #if self.orientation[0] == 'h': - #r = (self.bounds.top(), self.bounds.bottom()) - #else: - #r = (self.bounds.left(), self.bounds.right()) - r = [self.lines[0].value(), self.lines[1].value()] - return (min(r), max(r)) + r = (self.lines[0].value(), self.lines[1].value()) + if self.swapMode == 'sort': + return (min(r), max(r)) + else: + return r def setRegion(self, rgn): """Set the values for the edges of the region. @@ -101,7 +147,8 @@ def setRegion(self, rgn): self.blockLineSignal = False self.lines[1].setValue(rgn[1]) #self.blockLineSignal = False - self.lineMoved() + self.lineMoved(0) + self.lineMoved(1) self.lineMoveFinished() def setBrush(self, *br, **kargs): @@ -111,6 +158,13 @@ def setBrush(self, *br, **kargs): self.brush = fn.mkBrush(*br, **kargs) self.currentBrush = self.brush + def setHoverBrush(self, *br, **kargs): + """Set the brush that fills the region when the mouse is hovering over. + Can have any arguments that are valid + for :func:`mkBrush `. + """ + self.hoverBrush = fn.mkBrush(*br, **kargs) + def setBounds(self, bounds): """Optional [min, max] bounding values for the region. To have no bounds on the region use [None, None]. @@ -128,81 +182,67 @@ def setMovable(self, m): self.movable = m self.setAcceptHoverEvents(m) + def setSpan(self, mn, mx): + if self.span == (mn, mx): + return + self.span = (mn, mx) + self.lines[0].setSpan(mn, mx) + self.lines[1].setSpan(mn, mx) + self.update() + def boundingRect(self): - br = UIGraphicsItem.boundingRect(self) + br = self.viewRect() # bounds of containing ViewBox mapped to local coords. + rng = self.getRegion() - if self.orientation == LinearRegionItem.Vertical: + if self.orientation in ('vertical', LinearRegionItem.Vertical): br.setLeft(rng[0]) br.setRight(rng[1]) + length = br.height() + br.setBottom(br.top() + length * self.span[1]) + br.setTop(br.top() + length * self.span[0]) else: br.setTop(rng[0]) br.setBottom(rng[1]) - return br.normalized() + length = br.width() + br.setRight(br.left() + length * self.span[1]) + br.setLeft(br.left() + length * self.span[0]) + + br = br.normalized() + + if self._bounds != br: + self._bounds = br + self.prepareGeometryChange() + + return br def paint(self, p, *args): profiler = debug.Profiler() - UIGraphicsItem.paint(self, p, *args) p.setBrush(self.currentBrush) p.setPen(fn.mkPen(None)) p.drawRect(self.boundingRect()) def dataBounds(self, axis, frac=1.0, orthoRange=None): - if axis == self.orientation: + if axis == self._orientation_axis[self.orientation]: return self.getRegion() else: return None - def lineMoved(self): + def lineMoved(self, i): if self.blockLineSignal: return + + # lines swapped + if self.lines[0].value() > self.lines[1].value(): + if self.swapMode == 'block': + self.lines[i].setValue(self.lines[1-i].value()) + elif self.swapMode == 'push': + self.lines[1-i].setValue(self.lines[i].value()) + self.prepareGeometryChange() - #self.emit(QtCore.SIGNAL('regionChanged'), self) self.sigRegionChanged.emit(self) def lineMoveFinished(self): - #self.emit(QtCore.SIGNAL('regionChangeFinished'), self) self.sigRegionChangeFinished.emit(self) - - - #def updateBounds(self): - #vb = self.view().viewRect() - #vals = [self.lines[0].value(), self.lines[1].value()] - #if self.orientation[0] == 'h': - #vb.setTop(min(vals)) - #vb.setBottom(max(vals)) - #else: - #vb.setLeft(min(vals)) - #vb.setRight(max(vals)) - #if vb != self.bounds: - #self.bounds = vb - #self.rect.setRect(vb) - - #def mousePressEvent(self, ev): - #if not self.movable: - #ev.ignore() - #return - #for l in self.lines: - #l.mousePressEvent(ev) ## pass event to both lines so they move together - ##if self.movable and ev.button() == QtCore.Qt.LeftButton: - ##ev.accept() - ##self.pressDelta = self.mapToParent(ev.pos()) - QtCore.QPointF(*self.p) - ##else: - ##ev.ignore() - - #def mouseReleaseEvent(self, ev): - #for l in self.lines: - #l.mouseReleaseEvent(ev) - - #def mouseMoveEvent(self, ev): - ##print "move", ev.pos() - #if not self.movable: - #return - #self.lines[0].blockSignals(True) # only want to update once - #for l in self.lines: - #l.mouseMoveEvent(ev) - #self.lines[0].blockSignals(False) - ##self.setPos(self.mapToParent(ev.pos()) - self.pressDelta) - ##self.emit(QtCore.SIGNAL('dragged'), self) def mouseDragEvent(self, ev): if not self.movable or int(ev.button() & QtCore.Qt.LeftButton) == 0: @@ -218,12 +258,9 @@ def mouseDragEvent(self, ev): if not self.moving: return - #delta = ev.pos() - ev.lastPos() self.lines[0].blockSignals(True) # only want to update once for i, l in enumerate(self.lines): l.setPos(self.cursorOffsets[i] + ev.pos()) - #l.setPos(l.pos()+delta) - #l.mouseDragEvent(ev) self.lines[0].blockSignals(False) self.prepareGeometryChange() @@ -242,7 +279,6 @@ def mouseClickEvent(self, ev): self.sigRegionChanged.emit(self) self.sigRegionChangeFinished.emit(self) - def hoverEvent(self, ev): if self.movable and (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): self.setMouseHover(True) @@ -255,36 +291,7 @@ def setMouseHover(self, hover): return self.mouseHovering = hover if hover: - c = self.brush.color() - c.setAlpha(c.alpha() * 2) - self.currentBrush = fn.mkBrush(c) + self.currentBrush = self.hoverBrush else: self.currentBrush = self.brush self.update() - - #def hoverEnterEvent(self, ev): - #print "rgn hover enter" - #ev.ignore() - #self.updateHoverBrush() - - #def hoverMoveEvent(self, ev): - #print "rgn hover move" - #ev.ignore() - #self.updateHoverBrush() - - #def hoverLeaveEvent(self, ev): - #print "rgn hover leave" - #ev.ignore() - #self.updateHoverBrush(False) - - #def updateHoverBrush(self, hover=None): - #if hover is None: - #scene = self.scene() - #hover = scene.claimEvent(self, QtCore.Qt.LeftButton, scene.Drag) - - #if hover: - #self.currentBrush = fn.mkBrush(255, 0,0,100) - #else: - #self.currentBrush = self.brush - #self.update() - diff --git a/pyqtgraph/graphicsItems/PlotCurveItem.py b/pyqtgraph/graphicsItems/PlotCurveItem.py index d66a8a99bf..c3a58da2ed 100644 --- a/pyqtgraph/graphicsItems/PlotCurveItem.py +++ b/pyqtgraph/graphicsItems/PlotCurveItem.py @@ -1,10 +1,11 @@ +# -*- coding: utf-8 -*- from ..Qt import QtGui, QtCore try: from ..Qt import QtOpenGL HAVE_OPENGL = True except: HAVE_OPENGL = False - + import numpy as np from .GraphicsObject import GraphicsObject from .. import functions as fn @@ -15,75 +16,78 @@ __all__ = ['PlotCurveItem'] class PlotCurveItem(GraphicsObject): - - + + """ Class representing a single plot curve. Instances of this class are created automatically as part of PlotDataItem; these rarely need to be instantiated directly. - + Features: - + - Fast data update - Fill under curve - Mouse interaction - + ==================== =============================================== **Signals:** sigPlotChanged(self) Emitted when the data being plotted has changed sigClicked(self) Emitted when the curve is clicked ==================== =============================================== """ - + sigPlotChanged = QtCore.Signal(object) sigClicked = QtCore.Signal(object) - + def __init__(self, *args, **kargs): """ Forwards all arguments to :func:`setData `. - + Some extra arguments are accepted as well: - + ============== ======================================================= **Arguments:** parent The parent GraphicsObject (optional) - clickable If True, the item will emit sigClicked when it is + clickable If True, the item will emit sigClicked when it is clicked on. Defaults to False. ============== ======================================================= """ GraphicsObject.__init__(self, kargs.get('parent', None)) self.clear() - + ## this is disastrous for performance. #self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) - + self.metaData = {} self.opts = { - 'pen': fn.mkPen('w'), 'shadowPen': None, 'fillLevel': None, + 'fillOutline': False, 'brush': None, 'stepMode': False, 'name': None, 'antialias': getConfigOption('antialias'), 'connect': 'all', 'mouseWidth': 8, # width of shape responding to mouse click + 'compositionMode': None, } + if 'pen' not in kargs: + self.opts['pen'] = fn.mkPen('w') self.setClickable(kargs.get('clickable', False)) self.setData(*args, **kargs) - + def implements(self, interface=None): ints = ['plotData'] if interface is None: return ints return interface in ints - + def name(self): return self.opts.get('name', None) - + def setClickable(self, s, width=None): """Sets whether the item responds to mouse clicks. - + The *width* argument specifies the width in pixels orthogonal to the curve that will respond to a mouse click. """ @@ -91,23 +95,41 @@ def setClickable(self, s, width=None): if width is not None: self.opts['mouseWidth'] = width self._mouseShape = None - self._boundingRect = None - - + self._boundingRect = None + + def setCompositionMode(self, mode): + """Change the composition mode of the item (see QPainter::CompositionMode + in the Qt documentation). This is useful when overlaying multiple items. + + ============================================ ============================================================ + **Most common arguments:** + QtGui.QPainter.CompositionMode_SourceOver Default; image replaces the background if it + is opaque. Otherwise, it uses the alpha channel to blend + the image with the background. + QtGui.QPainter.CompositionMode_Overlay The image color is mixed with the background color to + reflect the lightness or darkness of the background. + QtGui.QPainter.CompositionMode_Plus Both the alpha and color of the image and background pixels + are added together. + QtGui.QPainter.CompositionMode_Multiply The output is the image color multiplied by the background. + ============================================ ============================================================ + """ + self.opts['compositionMode'] = mode + self.update() + def getData(self): return self.xData, self.yData - + def dataBounds(self, ax, frac=1.0, orthoRange=None): ## Need this to run as fast as possible. ## check cache first: cache = self._boundsCache[ax] if cache is not None and cache[0] == (frac, orthoRange): return cache[1] - + (x, y) = self.getData() if x is None or len(x) == 0: return (None, None) - + if ax == 0: d = x d2 = y @@ -120,7 +142,7 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): mask = (d2 >= orthoRange[0]) * (d2 <= orthoRange[1]) d = d[mask] #d2 = d2[mask] - + if len(d) == 0: return (None, None) @@ -132,8 +154,10 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): if any(np.isinf(b)): mask = np.isfinite(d) d = d[mask] + if len(d) == 0: + return (None, None) b = (d.min(), d.max()) - + elif frac <= 0.0: raise Exception("Value for parameter 'frac' must be > 0. (got %s)" % str(frac)) else: @@ -143,9 +167,9 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): b = np.percentile(d, [50 * (1 - frac), 50 * (1 + frac)]) ## adjust for fill level - if ax == 1 and self.opts['fillLevel'] is not None: + if ax == 1 and self.opts['fillLevel'] not in [None, 'enclosed']: b = (min(b[0], self.opts['fillLevel']), max(b[1], self.opts['fillLevel'])) - + ## Add pen width only if it is non-cosmetic. pen = self.opts['pen'] spen = self.opts['shadowPen'] @@ -153,10 +177,10 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): b = (b[0] - pen.widthF()*0.7072, b[1] + pen.widthF()*0.7072) if spen is not None and not spen.isCosmetic() and spen.style() != QtCore.Qt.NoPen: b = (b[0] - spen.widthF()*0.7072, b[1] + spen.widthF()*0.7072) - + self._boundsCache[ax] = [(frac, orthoRange), b] return b - + def pixelPadding(self): pen = self.opts['pen'] spen = self.opts['shadowPen'] @@ -173,13 +197,13 @@ def boundingRect(self): if self._boundingRect is None: (xmn, xmx) = self.dataBounds(ax=0) (ymn, ymx) = self.dataBounds(ax=1) - if xmn is None: + if xmn is None or ymn is None: return QtCore.QRectF() - + px = py = 0.0 pxPad = self.pixelPadding() if pxPad > 0: - # determine length of pixel in local x, y directions + # determine length of pixel in local x, y directions px, py = self.pixelVectors() try: px = 0 if px is None else px.length() @@ -189,68 +213,68 @@ def boundingRect(self): py = 0 if py is None else py.length() except OverflowError: py = 0 - + # return bounds expanded by pixel size px *= pxPad py *= pxPad #px += self._maxSpotWidth * 0.5 #py += self._maxSpotWidth * 0.5 self._boundingRect = QtCore.QRectF(xmn-px, ymn-py, (2*px)+xmx-xmn, (2*py)+ymx-ymn) - + return self._boundingRect - + def viewTransformChanged(self): self.invalidateBounds() self.prepareGeometryChange() - + #def boundingRect(self): #if self._boundingRect is None: #(x, y) = self.getData() #if x is None or y is None or len(x) == 0 or len(y) == 0: #return QtCore.QRectF() - - + + #if self.opts['shadowPen'] is not None: #lineWidth = (max(self.opts['pen'].width(), self.opts['shadowPen'].width()) + 1) #else: #lineWidth = (self.opts['pen'].width()+1) - - + + #pixels = self.pixelVectors() #if pixels == (None, None): #pixels = [Point(0,0), Point(0,0)] - + #xmin = x.min() #xmax = x.max() #ymin = y.min() #ymax = y.max() - + #if self.opts['fillLevel'] is not None: #ymin = min(ymin, self.opts['fillLevel']) #ymax = max(ymax, self.opts['fillLevel']) - + #xmin -= pixels[0].x() * lineWidth #xmax += pixels[0].x() * lineWidth #ymin -= abs(pixels[1].y()) * lineWidth #ymax += abs(pixels[1].y()) * lineWidth - + #self._boundingRect = QtCore.QRectF(xmin, ymin, xmax-xmin, ymax-ymin) #return self._boundingRect - + def invalidateBounds(self): self._boundingRect = None self._boundsCache = [None, None] - + def setPen(self, *args, **kargs): """Set the pen used to draw the curve.""" self.opts['pen'] = fn.mkPen(*args, **kargs) self.invalidateBounds() self.update() - + def setShadowPen(self, *args, **kargs): - """Set the shadow pen used to draw behind tyhe primary pen. - This pen must have a larger width than the primary + """Set the shadow pen used to draw behind the primary pen. + This pen must have a larger width than the primary pen to be visible. """ self.opts['shadowPen'] = fn.mkPen(*args, **kargs) @@ -262,7 +286,7 @@ def setBrush(self, *args, **kargs): self.opts['brush'] = fn.mkBrush(*args, **kargs) self.invalidateBounds() self.update() - + def setFillLevel(self, level): """Set the level filled to when filling under the curve""" self.opts['fillLevel'] = level @@ -272,17 +296,19 @@ def setFillLevel(self, level): def setData(self, *args, **kargs): """ - ============== ======================================================== + =============== ======================================================== **Arguments:** - x, y (numpy arrays) Data to show + x, y (numpy arrays) Data to show pen Pen to use when drawing. Any single argument accepted by :func:`mkPen ` is allowed. shadowPen Pen for drawing behind the primary pen. Usually this - is used to emphasize the curve by providing a + is used to emphasize the curve by providing a high-contrast border. Any single argument accepted by :func:`mkPen ` is allowed. fillLevel (float or None) Fill the area 'under' the curve to *fillLevel* + fillOutline (bool) If True, an outline surrounding the *fillLevel* + area is drawn. brush QBrush to use when filling. Any single argument accepted by :func:`mkBrush ` is allowed. antialias (bool) Whether to use antialiasing when drawing. This @@ -296,30 +322,35 @@ def setData(self, *args, **kargs): to be drawn. "finite" causes segments to be omitted if they are attached to nan or inf values. For any other connectivity, specify an array of boolean values. - ============== ======================================================== - + compositionMode See :func:`setCompositionMode + `. + =============== ======================================================== + If non-keyword arguments are used, they will be interpreted as setData(y) for a single argument and setData(x, y) for two arguments. - - + + """ self.updateData(*args, **kargs) - + def updateData(self, *args, **kargs): profiler = debug.Profiler() + if 'compositionMode' in kargs: + self.setCompositionMode(kargs['compositionMode']) + if len(args) == 1: kargs['y'] = args[0] elif len(args) == 2: kargs['x'] = args[0] kargs['y'] = args[1] - + if 'y' not in kargs or kargs['y'] is None: kargs['y'] = np.array([]) if 'x' not in kargs or kargs['x'] is None: kargs['x'] = np.arange(len(kargs['y'])) - + for k in ['x', 'y']: data = kargs[k] if isinstance(data, list): @@ -327,36 +358,37 @@ def updateData(self, *args, **kargs): kargs[k] = data if not isinstance(data, np.ndarray) or data.ndim > 1: raise Exception("Plot data must be 1D ndarray.") - if 'complex' in str(data.dtype): + if data.dtype.kind == 'c': raise Exception("Can not plot complex data types.") - + profiler("data checks") - + #self.setCacheMode(QtGui.QGraphicsItem.NoCache) ## Disabling and re-enabling the cache works around a bug in Qt 4.6 causing the cached results to display incorrectly ## Test this bug with test_PlotWidget and zoom in on the animated plot - self.invalidateBounds() - self.prepareGeometryChange() - self.informViewBoundsChanged() self.yData = kargs['y'].view(np.ndarray) self.xData = kargs['x'].view(np.ndarray) + self.invalidateBounds() + self.prepareGeometryChange() + self.informViewBoundsChanged() + profiler('copy') - + if 'stepMode' in kargs: self.opts['stepMode'] = kargs['stepMode'] - + if self.opts['stepMode'] is True: if len(self.xData) != len(self.yData)+1: ## allow difference of 1 for step mode plots raise Exception("len(X) must be len(Y)+1 since stepMode=True (got %s and %s)" % (self.xData.shape, self.yData.shape)) else: if self.xData.shape != self.yData.shape: ## allow difference of 1 for step mode plots raise Exception("X and Y arrays must be the same shape--got %s and %s." % (self.xData.shape, self.yData.shape)) - + self.path = None self.fillPath = None self._mouseShape = None #self.xDisp = self.yDisp = None - + if 'name' in kargs: self.opts['name'] = kargs['name'] if 'connect' in kargs: @@ -367,18 +399,20 @@ def updateData(self, *args, **kargs): self.setShadowPen(kargs['shadowPen']) if 'fillLevel' in kargs: self.setFillLevel(kargs['fillLevel']) + if 'fillOutline' in kargs: + self.opts['fillOutline'] = kargs['fillOutline'] if 'brush' in kargs: self.setBrush(kargs['brush']) if 'antialias' in kargs: self.opts['antialias'] = kargs['antialias'] - - + + profiler('set') self.update() profiler('update') self.sigPlotChanged.emit(self) profiler('emit') - + def generatePath(self, x, y): if self.opts['stepMode']: ## each value in the x/y arrays generates 2 points. @@ -397,9 +431,9 @@ def generatePath(self, x, y): y = y2.reshape(y2.size)[1:-1] y[0] = self.opts['fillLevel'] y[-1] = self.opts['fillLevel'] - + path = fn.arrayToQPath(x, y, connect=self.opts['connect']) - + return path @@ -412,7 +446,7 @@ def getPath(self): self.path = self.generatePath(*self.getData()) self.fillPath = None self._mouseShape = None - + return self.path @debug.warnOnException ## raising an exception here causes crash @@ -420,43 +454,46 @@ def paint(self, p, opt, widget): profiler = debug.Profiler() if self.xData is None or len(self.xData) == 0: return - + if HAVE_OPENGL and getConfigOption('enableExperimental') and isinstance(widget, QtOpenGL.QGLWidget): self.paintGL(p, opt, widget) return - + x = None y = None path = self.getPath() - profiler('generate path') - + if self._exportOpts is not False: aa = self._exportOpts.get('antialias', True) else: aa = self.opts['antialias'] - + p.setRenderHint(p.Antialiasing, aa) - - + + cmode = self.opts['compositionMode'] + if cmode is not None: + p.setCompositionMode(cmode) + if self.opts['brush'] is not None and self.opts['fillLevel'] is not None: if self.fillPath is None: if x is None: x,y = self.getData() p2 = QtGui.QPainterPath(self.path) - p2.lineTo(x[-1], self.opts['fillLevel']) - p2.lineTo(x[0], self.opts['fillLevel']) - p2.lineTo(x[0], y[0]) + if self.opts['fillLevel'] != 'enclosed': + p2.lineTo(x[-1], self.opts['fillLevel']) + p2.lineTo(x[0], self.opts['fillLevel']) + p2.lineTo(x[0], y[0]) p2.closeSubpath() self.fillPath = p2 - + profiler('generate fill path') p.fillPath(self.fillPath, self.opts['brush']) profiler('draw fill path') - - sp = fn.mkPen(self.opts['shadowPen']) - cp = fn.mkPen(self.opts['pen']) - + + sp = self.opts['shadowPen'] + cp = self.opts['pen'] + ## Copy pens and apply alpha adjustment #sp = QtGui.QPen(self.opts['shadowPen']) #cp = QtGui.QPen(self.opts['pen']) @@ -467,38 +504,39 @@ def paint(self, p, opt, widget): #c.setAlpha(c.alpha() * self.opts['alphaHint']) #pen.setColor(c) ##pen.setCosmetic(True) - - - + if sp is not None and sp.style() != QtCore.Qt.NoPen: p.setPen(sp) p.drawPath(path) p.setPen(cp) - p.drawPath(path) + if self.opts['fillOutline'] and self.fillPath is not None: + p.drawPath(self.fillPath) + else: + p.drawPath(path) profiler('drawPath') - + #print "Render hints:", int(p.renderHints()) #p.setPen(QtGui.QPen(QtGui.QColor(255,0,0))) #p.drawRect(self.boundingRect()) - + def paintGL(self, p, opt, widget): p.beginNativePainting() import OpenGL.GL as gl - + ## set clipping viewport view = self.getViewBox() if view is not None: rect = view.mapRectToItem(self, view.boundingRect()) #gl.glViewport(int(rect.x()), int(rect.y()), int(rect.width()), int(rect.height())) - + #gl.glTranslate(-rect.x(), -rect.y(), 0) - + gl.glEnable(gl.GL_STENCIL_TEST) gl.glColorMask(gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE, gl.GL_FALSE) # disable drawing to frame buffer gl.glDepthMask(gl.GL_FALSE) # disable drawing to depth buffer - gl.glStencilFunc(gl.GL_NEVER, 1, 0xFF) - gl.glStencilOp(gl.GL_REPLACE, gl.GL_KEEP, gl.GL_KEEP) - + gl.glStencilFunc(gl.GL_NEVER, 1, 0xFF) + gl.glStencilOp(gl.GL_REPLACE, gl.GL_KEEP, gl.GL_KEEP) + ## draw stencil pattern gl.glStencilMask(0xFF) gl.glClear(gl.GL_STENCIL_BUFFER_BIT) @@ -510,12 +548,12 @@ def paintGL(self, p, opt, widget): gl.glVertex2f(rect.x()+rect.width(), rect.y()) gl.glVertex2f(rect.x(), rect.y()+rect.height()) gl.glEnd() - + gl.glColorMask(gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE, gl.GL_TRUE) gl.glDepthMask(gl.GL_TRUE) gl.glStencilMask(0x00) gl.glStencilFunc(gl.GL_EQUAL, 1, 0xFF) - + try: x, y = self.getData() pos = np.empty((len(x), 2)) @@ -535,12 +573,12 @@ def paintGL(self, p, opt, widget): gl.glEnable(gl.GL_BLEND) gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) - gl.glDrawArrays(gl.GL_LINE_STRIP, 0, pos.size / pos.shape[-1]) + gl.glDrawArrays(gl.GL_LINE_STRIP, 0, int(pos.size / pos.shape[-1])) finally: gl.glDisableClientState(gl.GL_VERTEX_ARRAY) finally: p.endNativePainting() - + def clear(self): self.xData = None ## raw values self.yData = None @@ -556,7 +594,7 @@ def clear(self): def mouseShape(self): """ Return a QPainterPath representing the clickable shape of the curve - + """ if self._mouseShape is None: view = self.getViewBox() @@ -569,14 +607,14 @@ def mouseShape(self): mousePath = stroker.createStroke(path) self._mouseShape = self.mapFromItem(view, mousePath) return self._mouseShape - + def mouseClickEvent(self, ev): if not self.clickable or ev.button() != QtCore.Qt.LeftButton: return if self.mouseShape().contains(ev.pos()): ev.accept() self.sigClicked.emit(self) - + class ROIPlotItem(PlotCurveItem): @@ -591,7 +629,7 @@ def __init__(self, roi, data, img, axes=(0,1), xVals=None, color=None): #roi.connect(roi, QtCore.SIGNAL('regionChanged'), self.roiChangedEvent) roi.sigRegionChanged.connect(self.roiChangedEvent) #self.roiChangedEvent() - + def getRoiData(self): d = self.roi.getArrayRegion(self.roiData, self.roiImg, axes=self.axes) if d is None: @@ -599,8 +637,7 @@ def getRoiData(self): while d.ndim > 1: d = d.mean(axis=1) return d - + def roiChangedEvent(self): d = self.getRoiData() self.updateData(d, self.xVals) - diff --git a/pyqtgraph/graphicsItems/PlotDataItem.py b/pyqtgraph/graphicsItems/PlotDataItem.py index 37245becba..58a218c795 100644 --- a/pyqtgraph/graphicsItems/PlotDataItem.py +++ b/pyqtgraph/graphicsItems/PlotDataItem.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import numpy as np from .. import metaarray as metaarray from ..Qt import QtCore @@ -40,25 +41,30 @@ def __init__(self, *args, **kargs): **Data initialization arguments:** (x,y data only) =================================== ====================================== - PlotDataItem(xValues, yValues) x and y values may be any sequence (including ndarray) of real numbers - PlotDataItem(yValues) y values only -- x will be automatically set to range(len(y)) + PlotDataItem(xValues, yValues) x and y values may be any sequence + (including ndarray) of real numbers + PlotDataItem(yValues) y values only -- x will be + automatically set to range(len(y)) PlotDataItem(x=xValues, y=yValues) x and y given by keyword arguments - PlotDataItem(ndarray(Nx2)) numpy array with shape (N, 2) where x=data[:,0] and y=data[:,1] + PlotDataItem(ndarray(Nx2)) numpy array with shape (N, 2) where + ``x=data[:,0]`` and ``y=data[:,1]`` =================================== ====================================== **Data initialization arguments:** (x,y data AND may include spot style) - =========================== ========================================= - PlotDataItem(recarray) numpy array with dtype=[('x', float), ('y', float), ...] - PlotDataItem(list-of-dicts) [{'x': x, 'y': y, ...}, ...] - PlotDataItem(dict-of-lists) {'x': [...], 'y': [...], ...} - PlotDataItem(MetaArray) 1D array of Y values with X sepecified as axis values - OR 2D array with a column 'y' and extra columns as needed. - =========================== ========================================= + ============================ ========================================= + PlotDataItem(recarray) numpy array with ``dtype=[('x', float), + ('y', float), ...]`` + PlotDataItem(list-of-dicts) ``[{'x': x, 'y': y, ...}, ...]`` + PlotDataItem(dict-of-lists) ``{'x': [...], 'y': [...], ...}`` + PlotDataItem(MetaArray) 1D array of Y values with X sepecified as + axis values OR 2D array with a column 'y' + and extra columns as needed. + ============================ ========================================= **Line style keyword arguments:** - ========== ============================================================================== + ============ ============================================================================== connect Specifies how / whether vertexes should be connected. See :func:`arrayToQPath() ` pen Pen to use for drawing line between points. @@ -67,13 +73,14 @@ def __init__(self, *args, **kargs): shadowPen Pen for secondary line to draw behind the primary line. disabled by default. May be any single argument accepted by :func:`mkPen() ` fillLevel Fill the area between the curve and fillLevel - fillBrush Fill to use when fillLevel is specified. + fillOutline (bool) If True, an outline surrounding the *fillLevel* area is drawn. + fillBrush Fill to use when fillLevel is specified. May be any single argument accepted by :func:`mkBrush() ` stepMode If True, two orthogonal lines are drawn for each sample as steps. This is commonly used when drawing histograms. - Note that in this case, `len(x) == len(y) + 1` + Note that in this case, ``len(x) == len(y) + 1`` (added in version 0.9.9) - ========== ============================================================================== + ============ ============================================================================== **Point style keyword arguments:** (see :func:`ScatterPlotItem.setData() ` for more information) @@ -154,6 +161,7 @@ def __init__(self, *args, **kargs): 'pen': (200,200,200), 'shadowPen': None, 'fillLevel': None, + 'fillOutline': False, 'fillBrush': None, 'stepMode': None, @@ -442,13 +450,15 @@ def setData(self, *args, **kargs): if y is None: + self.updateItems() + profiler('update items') return if y is not None and x is None: x = np.arange(len(y)) - if isinstance(x, list): + if not isinstance(x, np.ndarray): x = np.array(x) - if isinstance(y, list): + if not isinstance(y, np.ndarray): y = np.array(y) self.xData = x.view(np.ndarray) ## one last check to make sure there are no MetaArrays getting by @@ -472,7 +482,7 @@ def setData(self, *args, **kargs): def updateItems(self): curveArgs = {} - for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillBrush', 'brush'), ('antialias', 'antialias'), ('connect', 'connect'), ('stepMode', 'stepMode')]: + for k,v in [('pen','pen'), ('shadowPen','shadowPen'), ('fillLevel','fillLevel'), ('fillOutline', 'fillOutline'), ('fillBrush', 'brush'), ('antialias', 'antialias'), ('connect', 'connect'), ('stepMode', 'stepMode')]: curveArgs[v] = self.opts[k] scatterArgs = {} @@ -490,6 +500,9 @@ def updateItems(self): self.curve.hide() if scatterArgs['symbol'] is not None: + + if self.opts.get('stepMode', False) is True: + x = 0.5 * (x[:-1] + x[1:]) self.scatter.setData(x=x, y=y, **scatterArgs) self.scatter.show() else: @@ -500,45 +513,22 @@ def getData(self): if self.xData is None: return (None, None) - #if self.xClean is None: - #nanMask = np.isnan(self.xData) | np.isnan(self.yData) | np.isinf(self.xData) | np.isinf(self.yData) - #if nanMask.any(): - #self.dataMask = ~nanMask - #self.xClean = self.xData[self.dataMask] - #self.yClean = self.yData[self.dataMask] - #else: - #self.dataMask = None - #self.xClean = self.xData - #self.yClean = self.yData - if self.xDisp is None: x = self.xData y = self.yData - - #ds = self.opts['downsample'] - #if isinstance(ds, int) and ds > 1: - #x = x[::ds] - ##y = resample(y[:len(x)*ds], len(x)) ## scipy.signal.resample causes nasty ringing - #y = y[::ds] if self.opts['fftMode']: x,y = self._fourierTransform(x, y) # Ignore the first bin for fft data if we have a logx scale if self.opts['logMode'][0]: x=x[1:] - y=y[1:] - if self.opts['logMode'][0]: - x = np.log10(x) - if self.opts['logMode'][1]: - y = np.log10(y) - #if any(self.opts['logMode']): ## re-check for NANs after log - #nanMask = np.isinf(x) | np.isinf(y) | np.isnan(x) | np.isnan(y) - #if any(nanMask): - #self.dataMask = ~nanMask - #x = x[self.dataMask] - #y = y[self.dataMask] - #else: - #self.dataMask = None + y=y[1:] + + with np.errstate(divide='ignore'): + if self.opts['logMode'][0]: + x = np.log10(x) + if self.opts['logMode'][1]: + y = np.log10(y) ds = self.opts['downsample'] if not isinstance(ds, int): @@ -547,25 +537,39 @@ def getData(self): if self.opts['autoDownsample']: # this option presumes that x-values have uniform spacing range = self.viewRect() - if range is not None: + if range is not None and len(x) > 1: dx = float(x[-1]-x[0]) / (len(x)-1) - x0 = (range.left()-x[0]) / dx - x1 = (range.right()-x[0]) / dx - width = self.getViewBox().width() - if width != 0.0: - ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor'])))) - ## downsampling is expensive; delay until after clipping. + if dx != 0.0: + x0 = (range.left()-x[0]) / dx + x1 = (range.right()-x[0]) / dx + width = self.getViewBox().width() + if width != 0.0: + ds = int(max(1, int((x1-x0) / (width*self.opts['autoDownsampleFactor'])))) + ## downsampling is expensive; delay until after clipping. if self.opts['clipToView']: view = self.getViewBox() if view is None or not view.autoRangeEnabled()[0]: - # this option presumes that x-values have uniform spacing + # this option presumes that x-values are in increasing order range = self.viewRect() if range is not None and len(x) > 1: + # clip to visible region extended by downsampling value, assuming + # uniform spacing of x-values, has O(1) performance dx = float(x[-1]-x[0]) / (len(x)-1) - # clip to visible region extended by downsampling value - x0 = np.clip(int((range.left()-x[0])/dx)-1*ds , 0, len(x)-1) - x1 = np.clip(int((range.right()-x[0])/dx)+2*ds , 0, len(x)-1) + x0 = np.clip(int((range.left()-x[0])/dx) - 1*ds, 0, len(x)-1) + x1 = np.clip(int((range.right()-x[0])/dx) + 2*ds, 0, len(x)-1) + + # if data has been clipped too strongly (in case of non-uniform + # spacing of x-values), refine the clipping region as required + # worst case performance: O(log(n)) + # best case performance: O(1) + if x[x0] > range.left(): + x0 = np.searchsorted(x, range.left()) - 1*ds + x0 = np.clip(x0, a_min=0, a_max=len(x)) + if x[x1] < range.right(): + x1 = np.searchsorted(x, range.right()) + 2*ds + x1 = np.clip(x1, a_min=0, a_max=len(x)) + x = x[x0:x1] y = y[x0:x1] @@ -591,8 +595,6 @@ def getData(self): self.xDisp = x self.yDisp = y - #print self.yDisp.shape, self.yDisp.min(), self.yDisp.max() - #print self.xDisp.shape, self.xDisp.min(), self.xDisp.max() return self.xDisp, self.yDisp def dataBounds(self, ax, frac=1.0, orthoRange=None): @@ -651,9 +653,9 @@ def clear(self): #self.yClean = None self.xDisp = None self.yDisp = None - self.curve.setData([]) - self.scatter.setData([]) - + self.curve.clear() + self.scatter.clear() + def appendData(self, *args, **kargs): pass @@ -679,10 +681,11 @@ def _fourierTransform(self, x, y): x2 = np.linspace(x[0], x[-1], len(x)) y = np.interp(x2, x, y) x = x2 - f = np.fft.fft(y) / len(y) - y = abs(f[1:len(f)/2]) - dt = x[-1] - x[0] - x = np.linspace(0, 0.5*len(x)/dt, len(y)) + n = y.size + f = np.fft.rfft(y) / n + d = float(x[-1]-x[0]) / (len(x)-1) + x = np.fft.rfftfreq(n, d) + y = np.abs(f) return x, y def dataType(obj): @@ -797,7 +800,7 @@ def isSequence(obj): #if isinstance(arg, basestring): #return self.data[arg] #elif isinstance(arg, int): - #return dict([(k, v[arg]) for k, v in self.data.iteritems()]) + #return dict([(k, v[arg]) for k, v in self.data.items()]) #elif isinstance(arg, tuple): #arg = self._orderArgs(arg) #return self.data[arg[1]][arg[0]] diff --git a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py index adc1950a1b..c61a35b2b7 100644 --- a/pyqtgraph/graphicsItems/PlotItem/PlotItem.py +++ b/pyqtgraph/graphicsItems/PlotItem/PlotItem.py @@ -1,21 +1,4 @@ # -*- coding: utf-8 -*- -""" -PlotItem.py - Graphics item implementing a scalable ViewBox with plotting powers. -Copyright 2010 Luke Campagnola -Distributed under MIT/X11 license. See license.txt for more information. - -This class is one of the workhorses of pyqtgraph. It implements a graphics item with -plots, labels, and scales which can be viewed inside a QGraphicsScene. If you want -a widget that can be added to your GUI, see PlotWidget instead. - -This class is very heavily featured: - - Automatically creates and manages PlotCurveItems - - Fast display and update of plots - - Manages zoom/pan ViewBox, scale, and label elements - - Automatic scaling when data changes - - Control panel with a huge feature set including averaging, decimation, - display, power spectrum, svg/png export, plot linking, and more. -""" import sys import weakref import numpy as np @@ -41,6 +24,8 @@ from .plotConfigTemplate_pyside import * elif QT_LIB == 'PyQt5': from .plotConfigTemplate_pyqt5 import * +elif QT_LIB == 'PySide2': + from .plotConfigTemplate_pyside2 import * __all__ = ['PlotItem'] @@ -51,17 +36,24 @@ HAVE_METAARRAY = False - - class PlotItem(GraphicsWidget): - - """ + """GraphicsWidget implementing a standard 2D plotting area with axes. + **Bases:** :class:`GraphicsWidget ` - Plot graphics item that can be added to any graphics scene. Implements axes, titles, and interactive viewbox. - PlotItem also provides some basic analysis functionality that may be accessed from the context menu. - Use :func:`plot() ` to create a new PlotDataItem and add it to the view. - Use :func:`addItem() ` to add any QGraphicsItem to the view. + This class provides the ViewBox-plus-axes that appear when using + :func:`pg.plot() `, :class:`PlotWidget `, + and :func:`GraphicsLayoutWidget.addPlot() `. + + It's main functionality is: + + - Manage placement of ViewBox, AxisItems, and LabelItems + - Create and manage a list of PlotDataItems displayed inside the ViewBox + - Implement a context menu with commonly used display and analysis options + + Use :func:`plot() ` to create a new PlotDataItem and + add it to the view. Use :func:`addItem() ` to + add any QGraphicsItem to the view. This class wraps several methods from its internal ViewBox: :func:`setXRange `, @@ -97,14 +89,13 @@ class PlotItem(GraphicsWidget): sigRangeChanged = QtCore.Signal(object, object) ## Emitted when the ViewBox range has changed sigYRangeChanged = QtCore.Signal(object, object) ## Emitted when the ViewBox Y range has changed sigXRangeChanged = QtCore.Signal(object, object) ## Emitted when the ViewBox X range has changed - - + lastFileDir = None def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None, axisItems=None, enableMenu=True, **kargs): """ Create a new PlotItem. All arguments are optional. - Any extra keyword arguments are passed to PlotItem.plot(). + Any extra keyword arguments are passed to :func:`PlotItem.plot() `. ============== ========================================================================================== **Arguments:** @@ -131,12 +122,9 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None ## Set up control buttons path = os.path.dirname(__file__) - #self.autoImageFile = os.path.join(path, 'auto.png') - #self.lockImageFile = os.path.join(path, 'lock.png') self.autoBtn = ButtonItem(pixmaps.getPixmap('auto'), 14, self) self.autoBtn.mode = 'auto' self.autoBtn.clicked.connect(self.autoBtnClicked) - #self.autoBtn.hide() self.buttonsHidden = False ## whether the user has requested buttons to be hidden self.mouseHovering = False @@ -165,26 +153,14 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None self.legend = None - ## Create and place axis items - if axisItems is None: - axisItems = {} + # Initialize axis items self.axes = {} - for k, pos in (('top', (1,1)), ('bottom', (3,1)), ('left', (2,0)), ('right', (2,2))): - if k in axisItems: - axis = axisItems[k] - else: - axis = AxisItem(orientation=k, parent=self) - axis.linkToView(self.vb) - self.axes[k] = {'item': axis, 'pos': pos} - self.layout.addItem(axis, *pos) - axis.setZValue(-1000) - axis.setFlag(axis.ItemNegativeZStacksBehindParent) + self.setAxisItems(axisItems) self.titleLabel = LabelItem('', size='11pt', parent=self) self.layout.addItem(self.titleLabel, 0, 1) self.setTitle(None) ## hide - for i in range(4): self.layout.setRowPreferredHeight(i, 0) self.layout.setRowMinimumHeight(i, 0) @@ -267,11 +243,6 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None self.ctrl.maxTracesCheck.toggled.connect(self.updateDecimation) self.ctrl.maxTracesSpin.valueChanged.connect(self.updateDecimation) - self.hideAxis('right') - self.hideAxis('top') - self.showAxis('left') - self.showAxis('bottom') - if labels is None: labels = {} for label in list(self.axes.keys()): @@ -287,8 +258,7 @@ def __init__(self, parent=None, name=None, labels=None, title=None, viewBox=None self.setTitle(title) if len(kargs) > 0: - self.plot(**kargs) - + self.plot(**kargs) def implements(self, interface=None): return interface in ['ViewBoxWrapper'] @@ -296,12 +266,10 @@ def implements(self, interface=None): def getViewBox(self): """Return the :class:`ViewBox ` contained within.""" return self.vb - ## Wrap a few methods from viewBox. #Important: don't use a settattr(m, getattr(self.vb, m)) as we'd be leaving the viebox alive #because we had a reference to an instance method (creating wrapper methods at runtime instead). - for m in ['setXRange', 'setYRange', 'setXLink', 'setYLink', 'setAutoPan', # NOTE: 'setAutoVisible', 'setRange', 'autoRange', 'viewRect', 'viewRange', # If you update this list, please 'setMouseEnabled', 'setLimits', 'enableAutoRange', 'disableAutoRange', # update the class docstring @@ -317,7 +285,58 @@ def method(self, *args, **kwargs): del _create_method - + def setAxisItems(self, axisItems=None): + """ + Place axis items as given by `axisItems`. Initializes non-existing axis items. + + ============== ========================================================================================== + **Arguments:** + *axisItems* Optional dictionary instructing the PlotItem to use pre-constructed items + for its axes. The dict keys must be axis names ('left', 'bottom', 'right', 'top') + and the values must be instances of AxisItem (or at least compatible with AxisItem). + ============== ========================================================================================== + """ + + + if axisItems is None: + axisItems = {} + + # Array containing visible axis items + # Also containing potentially hidden axes, but they are not touched so it does not matter + visibleAxes = ['left', 'bottom'] + visibleAxes.append(axisItems.keys()) # Note that it does not matter that this adds + # some values to visibleAxes a second time + + for k, pos in (('top', (1,1)), ('bottom', (3,1)), ('left', (2,0)), ('right', (2,2))): + if k in self.axes: + if k not in axisItems: + continue # Nothing to do here + + # Remove old axis + oldAxis = self.axes[k]['item'] + self.layout.removeItem(oldAxis) + oldAxis.scene().removeItem(oldAxis) + oldAxis.unlinkFromView() + + # Create new axis + if k in axisItems: + axis = axisItems[k] + if axis.scene() is not None: + if axis != self.axes[k]["item"]: + raise RuntimeError("Can't add an axis to multiple plots.") + else: + axis = AxisItem(orientation=k, parent=self) + + # Set up new axis + axis.linkToView(self.vb) + self.axes[k] = {'item': axis, 'pos': pos} + self.layout.addItem(axis, *pos) + axis.setZValue(-1000) + axis.setFlag(axis.ItemNegativeZStacksBehindParent) + + axisVisible = k in visibleAxes + self.showAxis(k, axisVisible) + def setLogMode(self, x=None, y=None): """ Set log scaling for x and/or y axes. @@ -356,16 +375,7 @@ def showGrid(self, x=None, y=None, alpha=None): v = np.clip(alpha, 0, 1)*self.ctrl.gridAlphaSlider.maximum() self.ctrl.gridAlphaSlider.setValue(v) - #def paint(self, *args): - #prof = debug.Profiler() - #QtGui.QGraphicsWidget.paint(self, *args) - - ## bad idea. - #def __getattr__(self, attr): ## wrap ms - #return getattr(self.vb, attr) - def close(self): - #print "delete", self ## Most of this crap is needed to avoid PySide trouble. ## The problem seems to be whenever scene.clear() leads to deletion of widgets (either through proxies or qgraphicswidgets) ## the solution is to manually remove all widgets before scene.clear() is called @@ -406,7 +416,6 @@ def viewGeometry(self): wr.adjust(pos.x(), pos.y(), pos.x(), pos.y()) return wr - def avgToggled(self, b): if b: self.recomputeAverages() @@ -547,8 +556,7 @@ def addItem(self, item, *args, **kargs): #self.plotChanged() #name = kargs.get('name', getattr(item, 'opts', {}).get('name', None)) if name is not None and hasattr(self, 'legend') and self.legend is not None: - self.legend.addItem(item, name=name) - + self.legend.addItem(item, name=name) def addDataItem(self, item, *args): print("PlotItem.addDataItem is deprecated. Use addItem instead.") @@ -573,15 +581,13 @@ def addLine(self, x=None, y=None, z=None, **kwds): :func:`InfiniteLine.__init__() `. Returns the item created. """ - pos = kwds.get('pos', x if x is not None else y) - angle = kwds.get('angle', 0 if x is None else 90) - line = InfiniteLine(pos, angle, **kwds) + kwds['pos'] = kwds.get('pos', x if x is not None else y) + kwds['angle'] = kwds.get('angle', 0 if x is None else 90) + line = InfiniteLine(**kwds) self.addItem(line) if z is not None: line.setZValue(z) - return line - - + return line def removeItem(self, item): """ @@ -593,14 +599,15 @@ def removeItem(self, item): if item in self.dataItems: self.dataItems.remove(item) - if item.scene() is not None: - self.vb.removeItem(item) + self.vb.removeItem(item) + if item in self.curves: self.curves.remove(item) self.updateDecimation() self.updateParamList() - #item.connect(item, QtCore.SIGNAL('plotChanged'), self.plotChanged) - #item.sigPlotChanged.connect(self.plotChanged) + + if self.legend is not None: + self.legend.removeItem(item) def clear(self): """ @@ -613,8 +620,7 @@ def clear(self): def clearPlots(self): for i in self.curves[:]: self.removeItem(i) - self.avgCurves = {} - + self.avgCurves = {} def plot(self, *args, **kargs): """ @@ -625,8 +631,6 @@ def plot(self, *args, **kargs): clear - clear all plots before displaying new data params - meta-parameters to associate with this data """ - - clear = kargs.get('clear', False) params = kargs.get('params', None) @@ -641,14 +645,20 @@ def plot(self, *args, **kargs): return item - def addLegend(self, size=None, offset=(30, 30)): + def addLegend(self, offset=(30, 30), **kwargs): """ - Create a new LegendItem and anchor it over the internal ViewBox. - Plots will be automatically displayed in the legend if they - are created with the 'name' argument. + Create a new :class:`~pyqtgraph.LegendItem` and anchor it over the + internal ViewBox. Plots will be automatically displayed in the legend + if they are created with the 'name' argument. + + If a LegendItem has already been created using this method, that + item will be returned rather than creating a new one. + + Accepts the same arguments as :meth:`~pyqtgraph.LegendItem`. """ - self.legend = LegendItem(size, offset) - self.legend.setParentItem(self.vb) + if self.legend is None: + self.legend = LegendItem(offset=offset, **kwargs) + self.legend.setParentItem(self.vb) return self.legend def scatterPlot(self, *args, **kargs): @@ -691,20 +701,11 @@ def updateParamList(self): self.paramList[p] = (i.checkState() == QtCore.Qt.Checked) - - ## Qt's SVG-writing capabilities are pretty terrible. def writeSvgCurves(self, fileName=None): if fileName is None: - self.fileDialog = FileDialog() - if PlotItem.lastFileDir is not None: - self.fileDialog.setDirectory(PlotItem.lastFileDir) - self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) - self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) - self.fileDialog.show() - self.fileDialog.fileSelected.connect(self.writeSvg) + self._chooseFilenameDialog(handler=self.writeSvg) return - #if fileName is None: - #fileName = QtGui.QFileDialog.getSaveFileName() + if isinstance(fileName, tuple): raise Exception("Not implemented yet..") fileName = str(fileName) @@ -714,7 +715,6 @@ def writeSvgCurves(self, fileName=None): xRange = rect.left(), rect.right() svg = "" - fh = open(fileName, 'w') dx = max(rect.right(),0) - min(rect.left(),0) ymn = min(rect.top(), rect.bottom()) @@ -728,60 +728,73 @@ def writeSvgCurves(self, fileName=None): sy *= 1000 sy *= -1 - #fh.write('\n' % (rect.left()*sx, rect.top()*sx, rect.width()*sy, rect.height()*sy)) - fh.write('\n') - fh.write('\n' % (rect.left()*sx, rect.right()*sx)) - fh.write('\n' % (rect.top()*sy, rect.bottom()*sy)) + with open(fileName, 'w') as fh: + # fh.write('\n' % (rect.left() * sx, + # rect.top() * sx, + # rect.width() * sy, + # rect.height()*sy)) + fh.write('\n') + fh.write('\n' % ( + rect.left() * sx, rect.right() * sx)) + fh.write('\n' % ( + rect.top() * sy, rect.bottom() * sy)) - - for item in self.curves: - if isinstance(item, PlotCurveItem): - color = fn.colorStr(item.pen.color()) - opacity = item.pen.color().alpha() / 255. - color = color[:6] - x, y = item.getData() - mask = (x > xRange[0]) * (x < xRange[1]) - mask[:-1] += mask[1:] - m2 = mask.copy() - mask[1:] += m2[:-1] - x = x[mask] - y = y[mask] - - x *= sx - y *= sy - - #fh.write('\n' % color) - fh.write('') - #fh.write("") - for item in self.dataItems: - if isinstance(item, ScatterPlotItem): - - pRect = item.boundingRect() - vRect = pRect.intersected(rect) - - for point in item.points(): - pos = point.pos() - if not rect.contains(pos): - continue - color = fn.colorStr(point.brush.color()) - opacity = point.brush.color().alpha() / 255. + for item in self.curves: + if isinstance(item, PlotCurveItem): + color = fn.colorStr(item.pen.color()) + opacity = item.pen.color().alpha() / 255. color = color[:6] - x = pos.x() * sx - y = pos.y() * sy - - fh.write('\n' % (x, y, color, opacity)) - - fh.write("\n") - - - + x, y = item.getData() + mask = (x > xRange[0]) * (x < xRange[1]) + mask[:-1] += mask[1:] + m2 = mask.copy() + mask[1:] += m2[:-1] + x = x[mask] + y = y[mask] + + x *= sx + y *= sy + + # fh.write('\n' % ( + # color, )) + fh.write('') + # fh.write("") + + for item in self.dataItems: + if isinstance(item, ScatterPlotItem): + pRect = item.boundingRect() + vRect = pRect.intersected(rect) + + for point in item.points(): + pos = point.pos() + if not rect.contains(pos): + continue + color = fn.colorStr(point.brush.color()) + opacity = point.brush.color().alpha() / 255. + color = color[:6] + x = pos.x() * sx + y = pos.y() * sy + + fh.write('\n' % ( + x, y, color, opacity)) + + fh.write("\n") + def writeSvg(self, fileName=None): if fileName is None: - fileName = QtGui.QFileDialog.getSaveFileName() + self._chooseFilenameDialog(handler=self.writeSvg) + return + fileName = str(fileName) PlotItem.lastFileDir = os.path.dirname(fileName) @@ -791,59 +804,36 @@ def writeSvg(self, fileName=None): def writeImage(self, fileName=None): if fileName is None: - self.fileDialog = FileDialog() - if PlotItem.lastFileDir is not None: - self.fileDialog.setDirectory(PlotItem.lastFileDir) - self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) - self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) - self.fileDialog.show() - self.fileDialog.fileSelected.connect(self.writeImage) + self._chooseFilenameDialog(handler=self.writeImage) return - #if fileName is None: - #fileName = QtGui.QFileDialog.getSaveFileName() - if isinstance(fileName, tuple): - raise Exception("Not implemented yet..") - fileName = str(fileName) - PlotItem.lastFileDir = os.path.dirname(fileName) - self.png = QtGui.QImage(int(self.size().width()), int(self.size().height()), QtGui.QImage.Format_ARGB32) - painter = QtGui.QPainter(self.png) - painter.setRenderHints(painter.Antialiasing | painter.TextAntialiasing) - self.scene().render(painter, QtCore.QRectF(), self.mapRectToScene(self.boundingRect())) - painter.end() - self.png.save(fileName) + + from ...exporters import ImageExporter + ex = ImageExporter(self) + ex.export(fileName) def writeCsv(self, fileName=None): if fileName is None: - self.fileDialog = FileDialog() - if PlotItem.lastFileDir is not None: - self.fileDialog.setDirectory(PlotItem.lastFileDir) - self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) - self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) - self.fileDialog.show() - self.fileDialog.fileSelected.connect(self.writeCsv) + self._chooseFilenameDialog(handler=self.writeCsv) return - #if fileName is None: - #fileName = QtGui.QFileDialog.getSaveFileName() + fileName = str(fileName) PlotItem.lastFileDir = os.path.dirname(fileName) - fd = open(fileName, 'w') data = [c.getData() for c in self.curves] - i = 0 - while True: - done = True - for d in data: - if i < len(d[0]): - fd.write('%g,%g,'%(d[0][i], d[1][i])) - done = False - else: - fd.write(' , ,') - fd.write('\n') - if done: - break - i += 1 - fd.close() - + with open(fileName, 'w') as fd: + i = 0 + while True: + done = True + for d in data: + if i < len(d[0]): + fd.write('%g,%g,' % (d[0][i], d[1][i])) + done = False + else: + fd.write(' , ,') + fd.write('\n') + if done: + break + i += 1 def saveState(self): state = self.stateGroup.state() @@ -879,7 +869,6 @@ def restoreState(self, state): 'viewRange': r, } self.vb.setState(state['view']) - def widgetGroupInterface(self): return (None, PlotItem.saveState, PlotItem.restoreState) @@ -978,9 +967,7 @@ def setClipToView(self, clip): def clipToViewMode(self): return self.ctrl.clipToViewCheck.isChecked() - - - + def updateDecimation(self): if self.ctrl.maxTracesCheck.isChecked(): numCurves = self.ctrl.maxTracesSpin.value() @@ -989,16 +976,13 @@ def updateDecimation(self): curves = self.curves[:] split = len(curves) - numCurves - for i in range(len(curves)): - if numCurves == -1 or i >= split: - curves[i].show() - else: + for curve in curves[split:]: + if numCurves != -1: if self.ctrl.forgetTracesCheck.isChecked(): - curves[i].clear() + curve.clear() self.removeItem(curves[i]) else: - curves[i].hide() - + curve.hide() def updateAlpha(self, *args): (alpha, auto) = self.alphaState() @@ -1025,7 +1009,6 @@ def pointMode(self): else: mode = False return mode - def resizeEvent(self, ev): if self.autoBtn is None: ## already closed down @@ -1034,7 +1017,6 @@ def resizeEvent(self, ev): y = self.size().height() - btnRect.height() self.autoBtn.setPos(0, y) - def getMenu(self): return self.ctrlMenu @@ -1054,8 +1036,8 @@ def setMenuEnabled(self, enableMenu=True, enableViewBoxMenu='same'): self._menuEnabled = enableMenu if enableViewBoxMenu is None: return - if enableViewBoxMenu is 'same': - enableViewBoxMenu = enableMenu + if enableViewBoxMenu == 'same': + enableViewBoxMenu = enableMenu self.vb.setMenuEnabled(enableViewBoxMenu) def menuEnabled(self): @@ -1068,7 +1050,6 @@ def hoverEvent(self, ev): self.mouseHovering = False self.updateButtons() - def getLabel(self, key): pass @@ -1117,7 +1098,6 @@ def setLabels(self, **kwds): v = (v,) self.setLabel(k, *v) - def showLabel(self, axis, show=True): """ Show or hide one of the plot's axis labels (the axis itself will be unaffected). @@ -1190,8 +1170,6 @@ def _plotArray(self, arr, x=None, **kargs): raise Exception("X array must be 1D to plot (shape is %s)" % x.shape) c = PlotCurveItem(arr, x=x, **kargs) return c - - def _plotMetaArray(self, arr, x=None, autoLabel=True, **kargs): inf = arr.infoCopy() @@ -1218,13 +1196,16 @@ def _plotMetaArray(self, arr, x=None, autoLabel=True, **kargs): self.setLabel('left', text=name, units=units) return c - def setExportMode(self, export, opts=None): GraphicsWidget.setExportMode(self, export, opts) self.updateButtons() - #if export: - #self.autoBtn.hide() - #else: - #self.autoBtn.show() + def _chooseFilenameDialog(self, handler): + self.fileDialog = FileDialog() + if PlotItem.lastFileDir is not None: + self.fileDialog.setDirectory(PlotItem.lastFileDir) + self.fileDialog.setFileMode(QtGui.QFileDialog.AnyFile) + self.fileDialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) + self.fileDialog.show() + self.fileDialog.fileSelected.connect(handler) diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui index dffc62d04f..12d8033ea6 100644 --- a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py index e09c997810..5ecc0438a1 100644 --- a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt.py @@ -148,7 +148,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) self.averageGroup.setToolTip(_translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).", None)) self.averageGroup.setTitle(_translate("Form", "Average", None)) self.clipToViewCheck.setToolTip(_translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.", None)) diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt5.py b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt5.py index e9fdff2483..817221f22e 100644 --- a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt5.py +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyqt5.py @@ -135,7 +135,7 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.averageGroup.setToolTip(_translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).")) self.averageGroup.setTitle(_translate("Form", "Average")) self.clipToViewCheck.setToolTip(_translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.")) diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py index aff3121179..d0fd1edda9 100644 --- a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside.py @@ -134,7 +134,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.averageGroup.setToolTip(QtGui.QApplication.translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).", None, QtGui.QApplication.UnicodeUTF8)) self.averageGroup.setTitle(QtGui.QApplication.translate("Form", "Average", None, QtGui.QApplication.UnicodeUTF8)) self.clipToViewCheck.setToolTip(QtGui.QApplication.translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside2.py b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside2.py new file mode 100644 index 0000000000..d801f2983f --- /dev/null +++ b/pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate_pyside2.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file './pyqtgraph/graphicsItems/PlotItem/plotConfigTemplate.ui' +# +# Created: Wed Mar 26 15:09:28 2014 +# by: PyQt5 UI code generator 5.0.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(481, 840) + self.averageGroup = QtWidgets.QGroupBox(Form) + self.averageGroup.setGeometry(QtCore.QRect(0, 640, 242, 182)) + self.averageGroup.setCheckable(True) + self.averageGroup.setChecked(False) + self.averageGroup.setObjectName("averageGroup") + self.gridLayout_5 = QtWidgets.QGridLayout(self.averageGroup) + self.gridLayout_5.setContentsMargins(0, 0, 0, 0) + self.gridLayout_5.setSpacing(0) + self.gridLayout_5.setObjectName("gridLayout_5") + self.avgParamList = QtWidgets.QListWidget(self.averageGroup) + self.avgParamList.setObjectName("avgParamList") + self.gridLayout_5.addWidget(self.avgParamList, 0, 0, 1, 1) + self.decimateGroup = QtWidgets.QFrame(Form) + self.decimateGroup.setGeometry(QtCore.QRect(10, 140, 191, 171)) + self.decimateGroup.setObjectName("decimateGroup") + self.gridLayout_4 = QtWidgets.QGridLayout(self.decimateGroup) + self.gridLayout_4.setContentsMargins(0, 0, 0, 0) + self.gridLayout_4.setSpacing(0) + self.gridLayout_4.setObjectName("gridLayout_4") + self.clipToViewCheck = QtWidgets.QCheckBox(self.decimateGroup) + self.clipToViewCheck.setObjectName("clipToViewCheck") + self.gridLayout_4.addWidget(self.clipToViewCheck, 7, 0, 1, 3) + self.maxTracesCheck = QtWidgets.QCheckBox(self.decimateGroup) + self.maxTracesCheck.setObjectName("maxTracesCheck") + self.gridLayout_4.addWidget(self.maxTracesCheck, 8, 0, 1, 2) + self.downsampleCheck = QtWidgets.QCheckBox(self.decimateGroup) + self.downsampleCheck.setObjectName("downsampleCheck") + self.gridLayout_4.addWidget(self.downsampleCheck, 0, 0, 1, 3) + self.peakRadio = QtWidgets.QRadioButton(self.decimateGroup) + self.peakRadio.setChecked(True) + self.peakRadio.setObjectName("peakRadio") + self.gridLayout_4.addWidget(self.peakRadio, 6, 1, 1, 2) + self.maxTracesSpin = QtWidgets.QSpinBox(self.decimateGroup) + self.maxTracesSpin.setObjectName("maxTracesSpin") + self.gridLayout_4.addWidget(self.maxTracesSpin, 8, 2, 1, 1) + self.forgetTracesCheck = QtWidgets.QCheckBox(self.decimateGroup) + self.forgetTracesCheck.setObjectName("forgetTracesCheck") + self.gridLayout_4.addWidget(self.forgetTracesCheck, 9, 0, 1, 3) + self.meanRadio = QtWidgets.QRadioButton(self.decimateGroup) + self.meanRadio.setObjectName("meanRadio") + self.gridLayout_4.addWidget(self.meanRadio, 3, 1, 1, 2) + self.subsampleRadio = QtWidgets.QRadioButton(self.decimateGroup) + self.subsampleRadio.setObjectName("subsampleRadio") + self.gridLayout_4.addWidget(self.subsampleRadio, 2, 1, 1, 2) + self.autoDownsampleCheck = QtWidgets.QCheckBox(self.decimateGroup) + self.autoDownsampleCheck.setChecked(True) + self.autoDownsampleCheck.setObjectName("autoDownsampleCheck") + self.gridLayout_4.addWidget(self.autoDownsampleCheck, 1, 2, 1, 1) + spacerItem = QtWidgets.QSpacerItem(30, 20, QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Minimum) + self.gridLayout_4.addItem(spacerItem, 2, 0, 1, 1) + self.downsampleSpin = QtWidgets.QSpinBox(self.decimateGroup) + self.downsampleSpin.setMinimum(1) + self.downsampleSpin.setMaximum(100000) + self.downsampleSpin.setProperty("value", 1) + self.downsampleSpin.setObjectName("downsampleSpin") + self.gridLayout_4.addWidget(self.downsampleSpin, 1, 1, 1, 1) + self.transformGroup = QtWidgets.QFrame(Form) + self.transformGroup.setGeometry(QtCore.QRect(0, 0, 154, 79)) + self.transformGroup.setObjectName("transformGroup") + self.gridLayout = QtWidgets.QGridLayout(self.transformGroup) + self.gridLayout.setObjectName("gridLayout") + self.fftCheck = QtWidgets.QCheckBox(self.transformGroup) + self.fftCheck.setObjectName("fftCheck") + self.gridLayout.addWidget(self.fftCheck, 0, 0, 1, 1) + self.logXCheck = QtWidgets.QCheckBox(self.transformGroup) + self.logXCheck.setObjectName("logXCheck") + self.gridLayout.addWidget(self.logXCheck, 1, 0, 1, 1) + self.logYCheck = QtWidgets.QCheckBox(self.transformGroup) + self.logYCheck.setObjectName("logYCheck") + self.gridLayout.addWidget(self.logYCheck, 2, 0, 1, 1) + self.pointsGroup = QtWidgets.QGroupBox(Form) + self.pointsGroup.setGeometry(QtCore.QRect(10, 550, 234, 58)) + self.pointsGroup.setCheckable(True) + self.pointsGroup.setObjectName("pointsGroup") + self.verticalLayout_5 = QtWidgets.QVBoxLayout(self.pointsGroup) + self.verticalLayout_5.setObjectName("verticalLayout_5") + self.autoPointsCheck = QtWidgets.QCheckBox(self.pointsGroup) + self.autoPointsCheck.setChecked(True) + self.autoPointsCheck.setObjectName("autoPointsCheck") + self.verticalLayout_5.addWidget(self.autoPointsCheck) + self.gridGroup = QtWidgets.QFrame(Form) + self.gridGroup.setGeometry(QtCore.QRect(10, 460, 221, 81)) + self.gridGroup.setObjectName("gridGroup") + self.gridLayout_2 = QtWidgets.QGridLayout(self.gridGroup) + self.gridLayout_2.setObjectName("gridLayout_2") + self.xGridCheck = QtWidgets.QCheckBox(self.gridGroup) + self.xGridCheck.setObjectName("xGridCheck") + self.gridLayout_2.addWidget(self.xGridCheck, 0, 0, 1, 2) + self.yGridCheck = QtWidgets.QCheckBox(self.gridGroup) + self.yGridCheck.setObjectName("yGridCheck") + self.gridLayout_2.addWidget(self.yGridCheck, 1, 0, 1, 2) + self.gridAlphaSlider = QtWidgets.QSlider(self.gridGroup) + self.gridAlphaSlider.setMaximum(255) + self.gridAlphaSlider.setProperty("value", 128) + self.gridAlphaSlider.setOrientation(QtCore.Qt.Horizontal) + self.gridAlphaSlider.setObjectName("gridAlphaSlider") + self.gridLayout_2.addWidget(self.gridAlphaSlider, 2, 1, 1, 1) + self.label = QtWidgets.QLabel(self.gridGroup) + self.label.setObjectName("label") + self.gridLayout_2.addWidget(self.label, 2, 0, 1, 1) + self.alphaGroup = QtWidgets.QGroupBox(Form) + self.alphaGroup.setGeometry(QtCore.QRect(10, 390, 234, 60)) + self.alphaGroup.setCheckable(True) + self.alphaGroup.setObjectName("alphaGroup") + self.horizontalLayout = QtWidgets.QHBoxLayout(self.alphaGroup) + self.horizontalLayout.setObjectName("horizontalLayout") + self.autoAlphaCheck = QtWidgets.QCheckBox(self.alphaGroup) + self.autoAlphaCheck.setChecked(False) + self.autoAlphaCheck.setObjectName("autoAlphaCheck") + self.horizontalLayout.addWidget(self.autoAlphaCheck) + self.alphaSlider = QtWidgets.QSlider(self.alphaGroup) + self.alphaSlider.setMaximum(1000) + self.alphaSlider.setProperty("value", 1000) + self.alphaSlider.setOrientation(QtCore.Qt.Horizontal) + self.alphaSlider.setObjectName("alphaSlider") + self.horizontalLayout.addWidget(self.alphaSlider) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.averageGroup.setToolTip(_translate("Form", "Display averages of the curves displayed in this plot. The parameter list allows you to choose parameters to average over (if any are available).")) + self.averageGroup.setTitle(_translate("Form", "Average")) + self.clipToViewCheck.setToolTip(_translate("Form", "Plot only the portion of each curve that is visible. This assumes X values are uniformly spaced.")) + self.clipToViewCheck.setText(_translate("Form", "Clip to View")) + self.maxTracesCheck.setToolTip(_translate("Form", "If multiple curves are displayed in this plot, check this box to limit the number of traces that are displayed.")) + self.maxTracesCheck.setText(_translate("Form", "Max Traces:")) + self.downsampleCheck.setText(_translate("Form", "Downsample")) + self.peakRadio.setToolTip(_translate("Form", "Downsample by drawing a saw wave that follows the min and max of the original data. This method produces the best visual representation of the data but is slower.")) + self.peakRadio.setText(_translate("Form", "Peak")) + self.maxTracesSpin.setToolTip(_translate("Form", "If multiple curves are displayed in this plot, check \"Max Traces\" and set this value to limit the number of traces that are displayed.")) + self.forgetTracesCheck.setToolTip(_translate("Form", "If MaxTraces is checked, remove curves from memory after they are hidden (saves memory, but traces can not be un-hidden).")) + self.forgetTracesCheck.setText(_translate("Form", "Forget hidden traces")) + self.meanRadio.setToolTip(_translate("Form", "Downsample by taking the mean of N samples.")) + self.meanRadio.setText(_translate("Form", "Mean")) + self.subsampleRadio.setToolTip(_translate("Form", "Downsample by taking the first of N samples. This method is fastest and least accurate.")) + self.subsampleRadio.setText(_translate("Form", "Subsample")) + self.autoDownsampleCheck.setToolTip(_translate("Form", "Automatically downsample data based on the visible range. This assumes X values are uniformly spaced.")) + self.autoDownsampleCheck.setText(_translate("Form", "Auto")) + self.downsampleSpin.setToolTip(_translate("Form", "Downsample data before plotting. (plot every Nth sample)")) + self.downsampleSpin.setSuffix(_translate("Form", "x")) + self.fftCheck.setText(_translate("Form", "Power Spectrum (FFT)")) + self.logXCheck.setText(_translate("Form", "Log X")) + self.logYCheck.setText(_translate("Form", "Log Y")) + self.pointsGroup.setTitle(_translate("Form", "Points")) + self.autoPointsCheck.setText(_translate("Form", "Auto")) + self.xGridCheck.setText(_translate("Form", "Show X Grid")) + self.yGridCheck.setText(_translate("Form", "Show Y Grid")) + self.label.setText(_translate("Form", "Opacity")) + self.alphaGroup.setTitle(_translate("Form", "Alpha")) + self.autoAlphaCheck.setText(_translate("Form", "Auto")) + diff --git a/pyqtgraph/graphicsItems/ROI.py b/pyqtgraph/graphicsItems/ROI.py index a913aa2e32..43bb921d91 100644 --- a/pyqtgraph/graphicsItems/ROI.py +++ b/pyqtgraph/graphicsItems/ROI.py @@ -26,7 +26,8 @@ __all__ = [ 'ROI', 'TestROI', 'RectROI', 'EllipseROI', 'CircleROI', 'PolygonROI', - 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', 'SpiralROI', 'CrosshairROI', + 'LineROI', 'MultiLineROI', 'MultiRectROI', 'LineSegmentROI', 'PolyLineROI', + 'CrosshairROI', ] @@ -42,9 +43,21 @@ class ROI(GraphicsObject): ROIs can be customized to have a variety of shapes (by subclassing or using any of the built-in subclasses) and any combination of draggable handles that allow the user to manipulate the ROI. - - - + + Default mouse interaction: + + * Left drag moves the ROI + * Left drag + Ctrl moves the ROI with position snapping + * Left drag + Alt rotates the ROI + * Left drag + Alt + Ctrl rotates the ROI with angle snapping + * Left drag + Shift scales the ROI + * Left drag + Shift + Ctrl scales the ROI with size snapping + + In addition to the above interaction modes, it is possible to attach any + number of handles to the ROI that can be dragged to change the ROI in + various ways (see the ROI.add____Handle methods). + + ================ =========================================================== **Arguments** pos (length-2 sequence) Indicates the position of the ROI's @@ -67,13 +80,17 @@ class ROI(GraphicsObject): to be integer multiples of *snapSize* when being resized by the user. Default is False. rotateSnap (bool) If True, the ROI angle is forced to a multiple of - 15 degrees when rotated by the user. Default is False. + the ROI's snap angle (default is 15 degrees) when rotated + by the user. Default is False. parent (QGraphicsItem) The graphics item parent of this ROI. It is generally not necessary to specify the parent. pen (QPen or argument to pg.mkPen) The pen to use when drawing the shape of the ROI. movable (bool) If True, the ROI can be moved by dragging anywhere inside the ROI. Default is True. + rotatable (bool) If True, the ROI can be rotated by mouse drag + ALT + resizable (bool) If True, the ROI can be resized by mouse drag + + SHIFT removable (bool) If True, the ROI will be given a context menu with an option to remove the ROI. The ROI emits sigRemoveRequested when this menu action is selected. @@ -111,15 +128,18 @@ class ROI(GraphicsObject): sigClicked = QtCore.Signal(object, object) sigRemoveRequested = QtCore.Signal(object) - def __init__(self, pos, size=Point(1, 1), angle=0.0, invertible=False, maxBounds=None, snapSize=1.0, scaleSnap=False, translateSnap=False, rotateSnap=False, parent=None, pen=None, movable=True, removable=False): - #QObjectWorkaround.__init__(self) + def __init__(self, pos, size=Point(1, 1), angle=0.0, invertible=False, maxBounds=None, + snapSize=1.0, scaleSnap=False, translateSnap=False, rotateSnap=False, + parent=None, pen=None, movable=True, rotatable=True, resizable=True, + removable=False): GraphicsObject.__init__(self, parent) self.setAcceptedMouseButtons(QtCore.Qt.NoButton) pos = Point(pos) size = Point(size) self.aspectLocked = False self.translatable = movable - self.rotateAllowed = True + self.rotatable = rotatable + self.resizable = resizable self.removable = removable self.menu = None @@ -146,8 +166,12 @@ def __init__(self, pos, size=Point(1, 1), angle=0.0, invertible=False, maxBounds self.snapSize = snapSize self.translateSnap = translateSnap self.rotateSnap = rotateSnap + self.rotateSnapAngle = 15.0 self.scaleSnap = scaleSnap - #self.setFlag(self.ItemIsSelectable, True) + self.scaleSnapSize = snapSize + + # Implement mouse handling in a separate class to allow easier customization + self.mouseDragHandler = MouseDragHandler(self) def getState(self): return self.stateCopy() @@ -231,6 +255,9 @@ def setPos(self, pos, y=None, update=True, finish=True): multiple change functions to be called sequentially while minimizing processing overhead and repeated signals. Setting ``update=False`` also forces ``finish=False``. """ + if update not in (True, False): + raise TypeError("update argument must be bool") + if y is None: pos = Point(pos) else: @@ -238,47 +265,99 @@ def setPos(self, pos, y=None, update=True, finish=True): if isinstance(y, bool): raise TypeError("Positional arguments to setPos() must be numerical.") pos = Point(pos, y) + self.state['pos'] = pos QtGui.QGraphicsItem.setPos(self, pos) if update: self.stateChanged(finish=finish) - def setSize(self, size, update=True, finish=True): - """Set the size of the ROI. May be specified as a QPoint, Point, or list of two values. - See setPos() for an explanation of the update and finish arguments. + def setSize(self, size, center=None, centerLocal=None, snap=False, update=True, finish=True): """ + Set the ROI's size. + + =============== ========================================================================== + **Arguments** + size (Point | QPointF | sequence) The final size of the ROI + center (None | Point) Optional center point around which the ROI is scaled, + expressed as [0-1, 0-1] over the size of the ROI. + centerLocal (None | Point) Same as *center*, but the position is expressed in the + local coordinate system of the ROI + snap (bool) If True, the final size is snapped to the nearest increment (see + ROI.scaleSnapSize) + update (bool) See setPos() + finish (bool) See setPos() + =============== ========================================================================== + """ + if update not in (True, False): + raise TypeError("update argument must be bool") size = Point(size) + if snap: + size[0] = round(size[0] / self.scaleSnapSize) * self.scaleSnapSize + size[1] = round(size[1] / self.scaleSnapSize) * self.scaleSnapSize + + if centerLocal is not None: + oldSize = Point(self.state['size']) + oldSize[0] = 1 if oldSize[0] == 0 else oldSize[0] + oldSize[1] = 1 if oldSize[1] == 0 else oldSize[1] + center = Point(centerLocal) / oldSize + + if center is not None: + center = Point(center) + c = self.mapToParent(Point(center) * self.state['size']) + c1 = self.mapToParent(Point(center) * size) + newPos = self.state['pos'] + c - c1 + self.setPos(newPos, update=False, finish=False) + self.prepareGeometryChange() self.state['size'] = size if update: self.stateChanged(finish=finish) + + def setAngle(self, angle, center=None, centerLocal=None, snap=False, update=True, finish=True): + """ + Set the ROI's rotation angle. - def setAngle(self, angle, update=True, finish=True): - """Set the angle of rotation (in degrees) for this ROI. - See setPos() for an explanation of the update and finish arguments. + =============== ========================================================================== + **Arguments** + angle (float) The final ROI angle in degrees + center (None | Point) Optional center point around which the ROI is rotated, + expressed as [0-1, 0-1] over the size of the ROI. + centerLocal (None | Point) Same as *center*, but the position is expressed in the + local coordinate system of the ROI + snap (bool) If True, the final ROI angle is snapped to the nearest increment + (default is 15 degrees; see ROI.rotateSnapAngle) + update (bool) See setPos() + finish (bool) See setPos() + =============== ========================================================================== """ + if update not in (True, False): + raise TypeError("update argument must be bool") + + if snap is True: + angle = round(angle / self.rotateSnapAngle) * self.rotateSnapAngle + self.state['angle'] = angle - tr = QtGui.QTransform() - #tr.rotate(-angle * 180 / np.pi) + tr = QtGui.QTransform() # note: only rotation is contained in the transform tr.rotate(angle) + if center is not None: + centerLocal = Point(center) * self.state['size'] + if centerLocal is not None: + centerLocal = Point(centerLocal) + # rotate to new angle, keeping a specific point anchored as the center of rotation + cc = self.mapToParent(centerLocal) - (tr.map(centerLocal) + self.state['pos']) + self.translate(cc, update=False) + self.setTransform(tr) if update: self.stateChanged(finish=finish) - def scale(self, s, center=[0,0], update=True, finish=True): + def scale(self, s, center=None, centerLocal=None, snap=False, update=True, finish=True): """ Resize the ROI by scaling relative to *center*. See setPos() for an explanation of the *update* and *finish* arguments. """ - c = self.mapToParent(Point(center) * self.state['size']) - self.prepareGeometryChange() newSize = self.state['size'] * s - c1 = self.mapToParent(Point(center) * newSize) - newPos = self.state['pos'] + c - c1 - - self.setSize(newSize, update=False) - self.setPos(newPos, update=update, finish=finish) - + self.setSize(newSize, center=center, centerLocal=centerLocal, snap=snap, update=update, finish=finish) def translate(self, *args, **kargs): """ @@ -307,20 +386,14 @@ def translate(self, *args, **kargs): newState = self.stateCopy() newState['pos'] = newState['pos'] + pt - ## snap position - #snap = kargs.get('snap', None) - #if (snap is not False) and not (snap is None and self.translateSnap is False): - snap = kargs.get('snap', None) if snap is None: snap = self.translateSnap if snap is not False: newState['pos'] = self.getSnapPosition(newState['pos'], snap=snap) - #d = ev.scenePos() - self.mapToScene(self.pressPos) if self.maxBounds is not None: r = self.stateRect(newState) - #r0 = self.sceneTransform().mapRect(self.boundingRect()) d = Point(0,0) if self.maxBounds.left() > r.left(): d[0] = self.maxBounds.left() - r.left() @@ -332,24 +405,30 @@ def translate(self, *args, **kargs): d[1] = self.maxBounds.bottom() - r.bottom() newState['pos'] += d - #self.state['pos'] = newState['pos'] update = kargs.get('update', True) finish = kargs.get('finish', True) self.setPos(newState['pos'], update=update, finish=finish) - #if 'update' not in kargs or kargs['update'] is True: - #self.stateChanged() - def rotate(self, angle, update=True, finish=True): + def rotate(self, angle, center=None, snap=False, update=True, finish=True): """ Rotate the ROI by *angle* degrees. - Also accepts *update* and *finish* arguments (see setPos() for a - description of these). + =============== ========================================================================== + **Arguments** + angle (float) The angle in degrees to rotate + center (None | Point) Optional center point around which the ROI is rotated, in + the local coordinate system of the ROI + snap (bool) If True, the final ROI angle is snapped to the nearest increment + (default is 15 degrees; see ROI.rotateSnapAngle) + update (bool) See setPos() + finish (bool) See setPos() + =============== ========================================================================== """ - self.setAngle(self.angle()+angle, update=update, finish=finish) + self.setAngle(self.angle()+angle, center=center, snap=snap, update=update, finish=finish) def handleMoveStarted(self): self.preMoveState = self.getState() + self.sigRegionChangeStarted.emit(self) def addTranslateHandle(self, pos, axes=None, item=None, name=None, index=None): """ @@ -574,7 +653,6 @@ def checkRemoveHandle(self, handle): ## Note: by default, handles are not user-removable even if this method returns True. return True - def getLocalHandlePositions(self, index=None): """Returns the position of handles in the ROI's coordinate system. @@ -620,7 +698,6 @@ def setSelected(self, s): for h in self.handles: h['item'].hide() - def hoverEvent(self, ev): hover = False if not ev.isExit(): @@ -635,10 +712,10 @@ def hoverEvent(self, ev): if hover: self.setMouseHover(True) - self.sigHoverEvent.emit(self) ev.acceptClicks(QtCore.Qt.LeftButton) ## If the ROI is hilighted, we should accept all clicks to avoid confusion. ev.acceptClicks(QtCore.Qt.RightButton) ev.acceptClicks(QtCore.Qt.MidButton) + self.sigHoverEvent.emit(self) else: self.setMouseHover(False) @@ -681,6 +758,9 @@ def getMenu(self): remAct.triggered.connect(self.removeClicked) self.menu.addAction(remAct) self.menu.remAct = remAct + # ROI menu may be requested when showing the handle context menu, so + # return the menu but disable it if the ROI isn't removable + self.menu.setEnabled(self.contextMenuEnabled()) return self.menu def removeClicked(self): @@ -688,34 +768,8 @@ def removeClicked(self): QtCore.QTimer.singleShot(0, lambda: self.sigRemoveRequested.emit(self)) def mouseDragEvent(self, ev): - if ev.isStart(): - #p = ev.pos() - #if not self.isMoving and not self.shape().contains(p): - #ev.ignore() - #return - if ev.button() == QtCore.Qt.LeftButton: - self.setSelected(True) - if self.translatable: - self.isMoving = True - self.preMoveState = self.getState() - self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos()) - self.sigRegionChangeStarted.emit(self) - ev.accept() - else: - ev.ignore() - - elif ev.isFinish(): - if self.translatable: - if self.isMoving: - self.stateChangeFinished() - self.isMoving = False - return + self.mouseDragHandler.mouseDragEvent(ev) - if self.translatable and self.isMoving and ev.buttons() == QtCore.Qt.LeftButton: - snap = True if (ev.modifiers() & QtCore.Qt.ControlModifier) else None - newPos = self.mapToParent(ev.pos()) + self.cursorOffset - self.translate(newPos - self.pos(), snap=snap, finish=False) - def mouseClickEvent(self, ev): if ev.button() == QtCore.Qt.RightButton and self.isMoving: ev.accept() @@ -729,6 +783,16 @@ def mouseClickEvent(self, ev): else: ev.ignore() + def _moveStarted(self): + self.isMoving = True + self.preMoveState = self.getState() + self.sigRegionChangeStarted.emit(self) + + def _moveFinished(self): + if self.isMoving: + self.stateChangeFinished() + self.isMoving = False + def cancelMove(self): self.isMoving = False self.setState(self.preMoveState) @@ -756,11 +820,6 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= else: raise Exception("New point location must be given in either 'parent' or 'scene' coordinates.") - - ## transform p0 and p1 into parent's coordinates (same as scene coords if there is no parent). I forget why. - #p0 = self.mapSceneToParent(p0) - #p1 = self.mapSceneToParent(p1) - ## Handles with a 'center' need to know their local position relative to the center point (lp0, lp1) if 'center' in h: c = h['center'] @@ -770,8 +829,6 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= if h['type'] == 't': snap = True if (modifiers & QtCore.Qt.ControlModifier) else None - #if self.translateSnap or (): - #snap = Point(self.snapSize, self.snapSize) self.translate(p1-p0, snap=snap, update=False) elif h['type'] == 'f': @@ -779,7 +836,6 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= h['item'].setPos(newPos) h['pos'] = newPos self.freeHandleMoved = True - #self.sigRegionChanged.emit(self) ## should be taken care of by call to stateChanged() elif h['type'] == 's': ## If a handle and its center have the same x or y value, we can't scale across that axis. @@ -790,8 +846,8 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= ## snap if self.scaleSnap or (modifiers & QtCore.Qt.ControlModifier): - lp1[0] = round(lp1[0] / self.snapSize) * self.snapSize - lp1[1] = round(lp1[1] / self.snapSize) * self.snapSize + lp1[0] = round(lp1[0] / self.scaleSnapSize) * self.scaleSnapSize + lp1[1] = round(lp1[1] / self.scaleSnapSize) * self.scaleSnapSize ## preserve aspect ratio (this can override snapping) if h['lockAspect'] or (modifiers & QtCore.Qt.AltModifier): @@ -839,7 +895,7 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= if h['type'] == 'rf': self.freeHandleMoved = True - if not self.rotateAllowed: + if not self.rotatable: return ## If the handle is directly over its center point, we can't compute an angle. try: @@ -853,7 +909,7 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= if ang is None: ## this should never happen.. return if self.rotateSnap or (modifiers & QtCore.Qt.ControlModifier): - ang = round(ang / 15.) * 15. ## 180/12 = 15 + ang = round(ang / self.rotateSnapAngle) * self.rotateSnapAngle ## create rotation transform tr = QtGui.QTransform() @@ -869,15 +925,14 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= r = self.stateRect(newState) if not self.maxBounds.contains(r): return - #self.setTransform(tr) self.setPos(newState['pos'], update=False) self.setAngle(ang, update=False) - #self.state = newState ## If this is a free-rotate handle, its distance from the center may change. if h['type'] == 'rf': h['item'].setPos(self.mapFromScene(p1)) ## changes ROI coordinates of handle + h['pos'] = self.mapFromParent(p1) elif h['type'] == 'sr': if h['center'][0] == h['pos'][0]: @@ -897,8 +952,7 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= if ang is None: return if self.rotateSnap or (modifiers & QtCore.Qt.ControlModifier): - #ang = round(ang / (np.pi/12.)) * (np.pi/12.) - ang = round(ang / 15.) * 15. + ang = round(ang / self.rotateSnapAngle) * self.rotateSnapAngle hs = abs(h['pos'][scaleAxis] - c[scaleAxis]) newState['size'][scaleAxis] = lp1.length() / hs @@ -921,10 +975,7 @@ def movePoint(self, handle, pos, modifiers=QtCore.Qt.KeyboardModifier(), finish= r = self.stateRect(newState) if not self.maxBounds.contains(r): return - #self.setTransform(tr) - #self.setPos(newState['pos'], update=False) - #self.prepareGeometryChange() - #self.state = newState + self.setState(newState, update=False) self.stateChanged(finish=finish) @@ -951,9 +1002,6 @@ def stateChanged(self, finish=True): if h['item'] in self.childItems(): p = h['pos'] h['item'].setPos(h['pos'] * self.state['size']) - #else: - # trans = self.state['pos']-self.lastState['pos'] - # h['item'].setPos(h['pos'] + h['item'].parentItem().mapFromParent(trans)) self.update() self.sigRegionChanged.emit(self) @@ -973,12 +1021,10 @@ def stateChangeFinished(self): def stateRect(self, state): r = QtCore.QRectF(0, 0, state['size'][0], state['size'][1]) tr = QtGui.QTransform() - #tr.rotate(-state['angle'] * 180 / np.pi) tr.rotate(-state['angle']) r = tr.mapRect(r) return r.adjusted(state['pos'][0], state['pos'][1], state['pos'][0], state['pos'][1]) - def getSnapPosition(self, pos, snap=None): ## Given that pos has been requested, return the nearest snap-to position ## optionally, snap may be passed in to specify a rectangular snap grid. @@ -998,7 +1044,6 @@ def boundingRect(self): return QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() def paint(self, p, opt, widget): - # p.save() # Note: don't use self.boundingRect here, because subclasses may need to redefine it. r = QtCore.QRectF(0, 0, self.state['size'][0], self.state['size'][1]).normalized() @@ -1007,7 +1052,6 @@ def paint(self, p, opt, widget): p.translate(r.left(), r.top()) p.scale(r.width(), r.height()) p.drawRect(0, 0, 1, 1) - # p.restore() def getArraySlice(self, data, img, axes=(0,1), returnSlice=True): """Return a tuple of slice objects that can be used to slice the region @@ -1063,9 +1107,9 @@ def getArraySlice(self, data, img, axes=(0,1), returnSlice=True): return bounds, tr def getArrayRegion(self, data, img, axes=(0,1), returnMappedCoords=False, **kwds): - """Use the position and orientation of this ROI relative to an imageItem + r"""Use the position and orientation of this ROI relative to an imageItem to pull a slice from an array. - + =================== ==================================================== **Arguments** data The array to slice from. Note that this array does @@ -1135,11 +1179,8 @@ def getAffineSliceParams(self, data, img, axes=(0,1), fromBoundingRect=False): lvx = np.sqrt(vx.x()**2 + vx.y()**2) lvy = np.sqrt(vy.x()**2 + vy.y()**2) - #pxLen = img.width() / float(data.shape[axes[0]]) ##img.width is number of pixels, not width of item. ##need pxWidth and pxHeight instead of pxLen ? - #sx = pxLen / lvx - #sy = pxLen / lvy sx = 1.0 / lvx sy = 1.0 / lvy @@ -1169,7 +1210,6 @@ def renderShapeMask(self, width, height): if width == 0 or height == 0: return np.empty((width, height), dtype=float) - # QImage(width, height, format) im = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32) im.fill(0x0) p = QtGui.QPainter(im) @@ -1199,27 +1239,6 @@ def getGlobalTransform(self, relativeTo=None): t1 = SRTTransform(relativeTo) t2 = SRTTransform(st) return t2/t1 - - - #st = self.getState() - - ### rotation - #ang = (st['angle']-relativeTo['angle']) * 180. / 3.14159265358 - #rot = QtGui.QTransform() - #rot.rotate(-ang) - - ### We need to come up with a universal transformation--one that can be applied to other objects - ### such that all maintain alignment. - ### More specifically, we need to turn the ROI's position and angle into - ### a rotation _around the origin_ and a translation. - - #p0 = Point(relativeTo['pos']) - - ### base position, rotated - #p1 = rot.map(p0) - - #trans = Point(st['pos']) - p1 - #return trans, ang def applyGlobalTransform(self, tr): st = self.getState() @@ -1241,8 +1260,6 @@ class Handle(UIGraphicsItem): Handles may be dragged to change the position, size, orientation, or other properties of the ROI they are attached to. - - """ types = { ## defines number of sides, start angle for each handle type 't': (4, np.pi/4), @@ -1257,9 +1274,6 @@ class Handle(UIGraphicsItem): sigRemoveRequested = QtCore.Signal(object) # self def __init__(self, radius, typ=None, pen=(200, 200, 220), parent=None, deletable=False): - #print " create item with parent", parent - #self.bounds = QtCore.QRectF(-1e-10, -1e-10, 2e-10, 2e-10) - #self.setFlags(self.ItemIgnoresTransformations | self.ItemSendsScenePositionChanges) self.rois = [] self.radius = radius self.typ = typ @@ -1278,7 +1292,6 @@ def __init__(self, radius, typ=None, pen=(200, 200, 220), parent=None, deletable self.deletable = deletable if deletable: self.setAcceptedMouseButtons(QtCore.Qt.RightButton) - #self.updateShape() self.setZValue(11) def connectROI(self, roi): @@ -1287,13 +1300,6 @@ def connectROI(self, roi): def disconnectROI(self, roi): self.rois.remove(roi) - #for i, r in enumerate(self.roi): - #if r[0] == roi: - #self.roi.pop(i) - - #def close(self): - #for r in self.roi: - #r.removeHandle(self) def setDeletable(self, b): self.deletable = b @@ -1319,21 +1325,12 @@ def hoverEvent(self, ev): else: self.currentPen = self.pen self.update() - #if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): - #self.currentPen = fn.mkPen(255, 255,0) - #else: - #self.currentPen = self.pen - #self.update() - - def mouseClickEvent(self, ev): ## right-click cancels drag if ev.button() == QtCore.Qt.RightButton and self.isMoving: self.isMoving = False ## prevents any further motion self.movePoint(self.startPos, finish=True) - #for r in self.roi: - #r[0].cancelMove() ev.accept() elif int(ev.button() & self.acceptedMouseButtons()) > 0: ev.accept() @@ -1342,12 +1339,6 @@ def mouseClickEvent(self, ev): self.sigClicked.emit(self, ev) else: ev.ignore() - - #elif self.deletable: - #ev.accept() - #self.raiseContextMenu(ev) - #else: - #ev.ignore() def buildMenu(self): menu = QtGui.QMenu() @@ -1418,36 +1409,10 @@ def buildPath(self): self.path.lineTo(x, y) def paint(self, p, opt, widget): - ### determine rotation of transform - #m = self.sceneTransform() - ##mi = m.inverted()[0] - #v = m.map(QtCore.QPointF(1, 0)) - m.map(QtCore.QPointF(0, 0)) - #va = np.arctan2(v.y(), v.x()) - - ### Determine length of unit vector in painter's coords - ##size = mi.map(Point(self.radius, self.radius)) - mi.map(Point(0, 0)) - ##size = (size.x()*size.x() + size.y() * size.y()) ** 0.5 - #size = self.radius - - #bounds = QtCore.QRectF(-size, -size, size*2, size*2) - #if bounds != self.bounds: - #self.bounds = bounds - #self.prepareGeometryChange() p.setRenderHints(p.Antialiasing, True) p.setPen(self.currentPen) - #p.rotate(va * 180. / 3.1415926) - #p.drawPath(self.path) p.drawPath(self.shape()) - #ang = self.startAng + va - #dt = 2*np.pi / self.sides - #for i in range(0, self.sides): - #x1 = size * cos(ang) - #y1 = size * sin(ang) - #x2 = size * cos(ang+dt) - #y2 = size * sin(ang+dt) - #ang += dt - #p.drawLine(Point(x1, y1), Point(x2, y2)) def shape(self): if self._shape is None: @@ -1459,18 +1424,10 @@ def shape(self): return self._shape def boundingRect(self): - #print 'roi:', self.roi s1 = self.shape() - #print " s1:", s1 - #s2 = self.shape() - #print " s2:", s2 - return self.shape().boundingRect() def generateShape(self): - ## determine rotation of transform - #m = self.sceneTransform() ## Qt bug: do not access sceneTransform() until we know this object has a scene. - #mi = m.inverted()[0] dt = self.deviceTransform() if dt is None: @@ -1488,22 +1445,80 @@ def generateShape(self): return dti.map(tr.map(self.path)) - def viewTransformChanged(self): GraphicsObject.viewTransformChanged(self) self._shape = None ## invalidate shape, recompute later if requested. self.update() - - #def itemChange(self, change, value): - #if change == self.ItemScenePositionHasChanged: - #self.updateShape() + + +class MouseDragHandler(object): + """Implements default mouse drag behavior for ROI (not for ROI handles). + """ + def __init__(self, roi): + self.roi = roi + self.dragMode = None + self.startState = None + self.snapModifier = QtCore.Qt.ControlModifier + self.translateModifier = QtCore.Qt.NoModifier + self.rotateModifier = QtCore.Qt.AltModifier + self.scaleModifier = QtCore.Qt.ShiftModifier + self.rotateSpeed = 0.5 + self.scaleSpeed = 1.01 + + def mouseDragEvent(self, ev): + roi = self.roi + + if ev.isStart(): + if ev.button() == QtCore.Qt.LeftButton: + roi.setSelected(True) + mods = ev.modifiers() & ~self.snapModifier + if roi.translatable and mods == self.translateModifier: + self.dragMode = 'translate' + elif roi.rotatable and mods == self.rotateModifier: + self.dragMode = 'rotate' + elif roi.resizable and mods == self.scaleModifier: + self.dragMode = 'scale' + else: + self.dragMode = None + + if self.dragMode is not None: + roi._moveStarted() + self.startPos = roi.mapToParent(ev.buttonDownPos()) + self.startState = roi.saveState() + self.cursorOffset = roi.pos() - self.startPos + ev.accept() + else: + ev.ignore() + else: + self.dragMode = None + ev.ignore() + + + if ev.isFinish() and self.dragMode is not None: + roi._moveFinished() + return + + # roi.isMoving becomes False if the move was cancelled by right-click + if not roi.isMoving or self.dragMode is None: + return + + snap = True if (ev.modifiers() & self.snapModifier) else None + pos = roi.mapToParent(ev.pos()) + if self.dragMode == 'translate': + newPos = pos + self.cursorOffset + roi.translate(newPos - roi.pos(), snap=snap, finish=False) + elif self.dragMode == 'rotate': + diff = self.rotateSpeed * (ev.scenePos() - ev.buttonDownScenePos()).x() + angle = self.startState['angle'] - diff + roi.setAngle(angle, centerLocal=ev.buttonDownPos(), snap=snap, finish=False) + elif self.dragMode == 'scale': + diff = self.scaleSpeed ** -(ev.scenePos() - ev.buttonDownScenePos()).y() + roi.setSize(Point(self.startState['size']) * diff, centerLocal=ev.buttonDownPos(), snap=snap, finish=False) class TestROI(ROI): def __init__(self, pos, size, **args): - #QtGui.QGraphicsRectItem.__init__(self, pos[0], pos[1], size[0], size[1]) ROI.__init__(self, pos, size, **args) - #self.addTranslateHandle([0, 0]) self.addTranslateHandle([0.5, 0.5]) self.addScaleHandle([1, 1], [0, 0]) self.addScaleHandle([0, 0], [1, 1]) @@ -1513,11 +1528,10 @@ def __init__(self, pos, size, **args): self.addRotateHandle([0, 1], [1, 1]) - class RectROI(ROI): - """ + r""" Rectangular ROI subclass with a single scale handle at the top-right corner. - + ============== ============================================================= **Arguments** pos (length-2 sequence) The position of the ROI origin. @@ -1532,21 +1546,19 @@ class RectROI(ROI): """ def __init__(self, pos, size, centered=False, sideScalers=False, **args): - #QtGui.QGraphicsRectItem.__init__(self, 0, 0, size[0], size[1]) ROI.__init__(self, pos, size, **args) if centered: center = [0.5, 0.5] else: center = [0, 0] - #self.addTranslateHandle(center) self.addScaleHandle([1, 1], center) if sideScalers: self.addScaleHandle([1, 0.5], [center[0], 0.5]) self.addScaleHandle([0.5, 1], [0.5, center[1]]) class LineROI(ROI): - """ + r""" Rectangular ROI subclass with scale-rotate handles on either side. This allows the ROI to be positioned as if moving the ends of a line segment. A third handle controls the width of the ROI orthogonal to its "line" axis. @@ -1579,11 +1591,13 @@ def __init__(self, pos1, pos2, width, **args): + + class MultiRectROI(QtGui.QGraphicsObject): - """ - Chain of rectangular ROIs connected by handles. - - This is generally used to mark a curved path through + r""" + Chain of rectangular ROIs connected by handles. + + This is generally used to mark a curved path through an image similarly to PolyLineROI. It differs in that each segment of the chain is rectangular instead of linear and thus has width. @@ -1648,7 +1662,6 @@ def getArrayRegion(self, arr, img=None, axes=(0,1), **kwds): rgn = l.getArrayRegion(arr, img, axes=axes, **kwds) if rgn is None: continue - #return None rgns.append(rgn) #print l.state['size'] @@ -1659,7 +1672,7 @@ def getArrayRegion(self, arr, img=None, axes=(0,1), **kwds): ms = min([r.shape[axes[1]] for r in rgns]) sl = [slice(None)] * rgns[0].ndim sl[axes[1]] = slice(0,ms) - rgns = [r[sl] for r in rgns] + rgns = [r[tuple(sl)] for r in rgns] #print [r.shape for r in rgns], axes return np.concatenate(rgns, axis=axes[0]) @@ -1718,12 +1731,12 @@ def __init__(self, *args, **kwds): MultiRectROI.__init__(self, *args, **kwds) print("Warning: MultiLineROI has been renamed to MultiRectROI. (and MultiLineROI may be redefined in the future)") - + class EllipseROI(ROI): - """ + r""" Elliptical ROI subclass with one scale handle and one rotation handle. - - + + ============== ============================================================= **Arguments** pos (length-2 sequence) The position of the ROI's origin. @@ -1733,11 +1746,18 @@ class EllipseROI(ROI): """ def __init__(self, pos, size, **args): - #QtGui.QGraphicsRectItem.__init__(self, 0, 0, size[0], size[1]) + self.path = None ROI.__init__(self, pos, size, **args) + self.sigRegionChanged.connect(self._clearPath) + self._addHandles() + + def _addHandles(self): self.addRotateHandle([1.0, 0.5], [0.5, 0.5]) self.addScaleHandle([0.5*2.**-0.5 + 0.5, 0.5*2.**-0.5 + 0.5], [0.5, 0.5]) + def _clearPath(self): + self.path = None + def paint(self, p, opt, widget): r = self.boundingRect() p.setRenderHint(QtGui.QPainter.Antialiasing) @@ -1760,6 +1780,7 @@ def getArrayRegion(self, arr, img=None, axes=(0, 1), **kwds): return arr w = arr.shape[axes[0]] h = arr.shape[axes[1]] + ## generate an ellipsoidal mask mask = np.fromfunction(lambda x,y: (((x+0.5)/(w/2.)-1)**2+ ((y+0.5)/(h/2.)-1)**2)**0.5 < 1, (w, h)) @@ -1772,13 +1793,33 @@ def getArrayRegion(self, arr, img=None, axes=(0, 1), **kwds): return arr * mask def shape(self): - self.path = QtGui.QPainterPath() - self.path.addEllipse(self.boundingRect()) + if self.path is None: + path = QtGui.QPainterPath() + + # Note: Qt has a bug where very small ellipses (radius <0.001) do + # not correctly intersect with mouse position (upper-left and + # lower-right quadrants are not clickable). + #path.addEllipse(self.boundingRect()) + + # Workaround: manually draw the path. + br = self.boundingRect() + center = br.center() + r1 = br.width() / 2. + r2 = br.height() / 2. + theta = np.linspace(0, 2*np.pi, 24) + x = center.x() + r1 * np.cos(theta) + y = center.y() + r2 * np.sin(theta) + path.moveTo(x[0], y[0]) + for i in range(1, len(x)): + path.lineTo(x[i], y[i]) + self.path = path + return self.path + class CircleROI(EllipseROI): - """ + r""" Circular ROI subclass. Behaves exactly as EllipseROI, but may only be scaled proportionally to maintain its aspect ratio. @@ -1790,10 +1831,15 @@ class CircleROI(EllipseROI): ============== ============================================================= """ - def __init__(self, pos, size, **args): - ROI.__init__(self, pos, size, **args) + def __init__(self, pos, size=None, radius=None, **args): + if size is None: + if radius is None: + raise TypeError("Must provide either size or radius.") + size = (radius*2, radius*2) + EllipseROI.__init__(self, pos, size, **args) self.aspectLocked = True - #self.addTranslateHandle([0.5, 0.5]) + + def _addHandles(self): self.addScaleHandle([0.5*2.**-0.5 + 0.5, 0.5*2.**-0.5 + 0.5], [0.5, 0.5]) @@ -1804,22 +1850,14 @@ def __init__(self, positions, pos=None, **args): if pos is None: pos = [0,0] ROI.__init__(self, pos, [1,1], **args) - #ROI.__init__(self, positions[0]) for p in positions: self.addFreeHandle(p) self.setZValue(1000) print("Warning: PolygonROI is deprecated. Use PolyLineROI instead.") - def listPoints(self): return [p['item'].pos() for p in self.handles] - #def movePoint(self, *args, **kargs): - #ROI.movePoint(self, *args, **kargs) - #self.prepareGeometryChange() - #for h in self.handles: - #h['pos'] = h['item'].pos() - def paint(self, p, *args): p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) @@ -1846,16 +1884,15 @@ def stateCopy(self): sc['pos'] = Point(self.state['pos']) sc['size'] = Point(self.state['size']) sc['angle'] = self.state['angle'] - #sc['handles'] = self.handles return sc - + class PolyLineROI(ROI): - """ + r""" Container class for multiple connected LineSegmentROIs. - + This class allows the user to draw paths of multiple line segments. - + ============== ============================================================= **Arguments** positions (list of length-2 sequences) The list of points in the path. @@ -2047,9 +2084,9 @@ def setPen(self, *args, **kwds): class LineSegmentROI(ROI): - """ + r""" ROI subclass with two freely-moving handles defining a line. - + ============== ============================================================= **Arguments** positions (list of two length-2 sequences) The endpoints of the line @@ -2066,13 +2103,16 @@ def __init__(self, positions=(None, None), pos=None, handles=(None,None), **args pos = [0,0] ROI.__init__(self, pos, [1,1], **args) - #ROI.__init__(self, positions[0]) if len(positions) > 2: raise Exception("LineSegmentROI must be defined by exactly 2 positions. For more points, use PolyLineROI.") for i, p in enumerate(positions): self.addFreeHandle(p, item=handles[i]) - + + @property + def endpoints(self): + # must not be cached because self.handles may change. + return [h['item'] for h in self.handles] def listPoints(self): return [p['item'].pos() for p in self.handles] @@ -2080,8 +2120,8 @@ def listPoints(self): def paint(self, p, *args): p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) - h1 = self.handles[0]['item'].pos() - h2 = self.handles[1]['item'].pos() + h1 = self.endpoints[0].pos() + h2 = self.endpoints[1].pos() p.drawLine(h1, h2) def boundingRect(self): @@ -2090,8 +2130,8 @@ def boundingRect(self): def shape(self): p = QtGui.QPainterPath() - h1 = self.handles[0]['item'].pos() - h2 = self.handles[1]['item'].pos() + h1 = self.endpoints[0].pos() + h2 = self.endpoints[1].pos() dh = h2-h1 if dh.length() == 0: return p @@ -2109,7 +2149,7 @@ def shape(self): return p - def getArrayRegion(self, data, img, axes=(0,1), order=1, **kwds): + def getArrayRegion(self, data, img, axes=(0,1), order=1, returnMappedCoords=False, **kwds): """ Use the position of this ROI relative to an imageItem to pull a slice from an array. @@ -2119,16 +2159,15 @@ def getArrayRegion(self, data, img, axes=(0,1), order=1, **kwds): See ROI.getArrayRegion() for a description of the arguments. """ - - imgPts = [self.mapToItem(img, h['item'].pos()) for h in self.handles] + imgPts = [self.mapToItem(img, h.pos()) for h in self.endpoints] rgns = [] - for i in range(len(imgPts)-1): - d = Point(imgPts[i+1] - imgPts[i]) - o = Point(imgPts[i]) - r = fn.affineSlice(data, shape=(int(d.length()),), vectors=[Point(d.norm())], origin=o, axes=axes, order=order, **kwds) - rgns.append(r) - - return np.concatenate(rgns, axis=axes[0]) + coords = [] + + d = Point(imgPts[1] - imgPts[0]) + o = Point(imgPts[0]) + rgn = fn.affineSlice(data, shape=(int(d.length()),), vectors=[Point(d.norm())], origin=o, axes=axes, order=order, returnCoords=returnMappedCoords, **kwds) + + return rgn class _PolyLineSegment(LineSegmentROI): @@ -2157,85 +2196,11 @@ def hoverEvent(self, ev): return LineSegmentROI.hoverEvent(self, ev) -class SpiralROI(ROI): - def __init__(self, pos=None, size=None, **args): - if size == None: - size = [100e-6,100e-6] - if pos == None: - pos = [0,0] - ROI.__init__(self, pos, size, **args) - self.translateSnap = False - self.addFreeHandle([0.25,0], name='a') - self.addRotateFreeHandle([1,0], [0,0], name='r') - #self.getRadius() - #QtCore.connect(self, QtCore.SIGNAL('regionChanged'), self. - - - def getRadius(self): - radius = Point(self.handles[1]['item'].pos()).length() - #r2 = radius[1] - #r3 = r2[0] - return radius - - def boundingRect(self): - r = self.getRadius() - return QtCore.QRectF(-r*1.1, -r*1.1, 2.2*r, 2.2*r) - #return self.bounds - - #def movePoint(self, *args, **kargs): - #ROI.movePoint(self, *args, **kargs) - #self.prepareGeometryChange() - #for h in self.handles: - #h['pos'] = h['item'].pos()/self.state['size'][0] - - def stateChanged(self, finish=True): - ROI.stateChanged(self, finish=finish) - if len(self.handles) > 1: - self.path = QtGui.QPainterPath() - h0 = Point(self.handles[0]['item'].pos()).length() - a = h0/(2.0*np.pi) - theta = 30.0*(2.0*np.pi)/360.0 - self.path.moveTo(QtCore.QPointF(a*theta*cos(theta), a*theta*sin(theta))) - x0 = a*theta*cos(theta) - y0 = a*theta*sin(theta) - radius = self.getRadius() - theta += 20.0*(2.0*np.pi)/360.0 - i = 0 - while Point(x0, y0).length() < radius and i < 1000: - x1 = a*theta*cos(theta) - y1 = a*theta*sin(theta) - self.path.lineTo(QtCore.QPointF(x1,y1)) - theta += 20.0*(2.0*np.pi)/360.0 - x0 = x1 - y0 = y1 - i += 1 - - - return self.path - - - def shape(self): - p = QtGui.QPainterPath() - p.addEllipse(self.boundingRect()) - return p - - def paint(self, p, *args): - p.setRenderHint(QtGui.QPainter.Antialiasing) - #path = self.shape() - p.setPen(self.currentPen) - p.drawPath(self.path) - p.setPen(QtGui.QPen(QtGui.QColor(255,0,0))) - p.drawPath(self.shape()) - p.setPen(QtGui.QPen(QtGui.QColor(0,0,255))) - p.drawRect(self.boundingRect()) - - class CrosshairROI(ROI): """A crosshair ROI whose position is at the center of the crosshairs. By default, it is scalable, rotatable and translatable.""" def __init__(self, pos=None, size=None, **kargs): if size == None: - #size = [100e-6,100e-6] size=[1,1] if pos == None: pos = [0,0] @@ -2251,16 +2216,8 @@ def invalidate(self): self.prepareGeometryChange() def boundingRect(self): - #size = self.size() - #return QtCore.QRectF(-size[0]/2., -size[1]/2., size[0], size[1]).normalized() return self.shape().boundingRect() - #def getRect(self): - ### same as boundingRect -- for internal use so that boundingRect can be re-implemented in subclasses - #size = self.size() - #return QtCore.QRectF(-size[0]/2., -size[1]/2., size[0], size[1]).normalized() - - def shape(self): if self._shape is None: radius = self.getState()['size'][1] @@ -2274,58 +2231,43 @@ def shape(self): stroker.setWidth(10) outline = stroker.createStroke(p) self._shape = self.mapFromDevice(outline) - - - ##h1 = self.handles[0]['item'].pos() - ##h2 = self.handles[1]['item'].pos() - #w1 = Point(-0.5, 0)*self.size() - #w2 = Point(0.5, 0)*self.size() - #h1 = Point(0, -0.5)*self.size() - #h2 = Point(0, 0.5)*self.size() - - #dh = h2-h1 - #dw = w2-w1 - #if dh.length() == 0 or dw.length() == 0: - #return p - #pxv = self.pixelVectors(dh)[1] - #if pxv is None: - #return p - - #pxv *= 4 - - #p.moveTo(h1+pxv) - #p.lineTo(h2+pxv) - #p.lineTo(h2-pxv) - #p.lineTo(h1-pxv) - #p.lineTo(h1+pxv) - - #pxv = self.pixelVectors(dw)[1] - #if pxv is None: - #return p - - #pxv *= 4 - - #p.moveTo(w1+pxv) - #p.lineTo(w2+pxv) - #p.lineTo(w2-pxv) - #p.lineTo(w1-pxv) - #p.lineTo(w1+pxv) return self._shape def paint(self, p, *args): - #p.save() - #r = self.getRect() radius = self.getState()['size'][1] p.setRenderHint(QtGui.QPainter.Antialiasing) p.setPen(self.currentPen) - #p.translate(r.left(), r.top()) - #p.scale(r.width()/10., r.height()/10.) ## need to scale up a little because drawLine has trouble dealing with 0.5 - #p.drawLine(0,5, 10,5) - #p.drawLine(5,0, 5,10) - #p.restore() p.drawLine(Point(0, -radius), Point(0, radius)) p.drawLine(Point(-radius, 0), Point(radius, 0)) +class RulerROI(LineSegmentROI): + def paint(self, p, *args): + LineSegmentROI.paint(self, p, *args) + h1 = self.handles[0]['item'].pos() + h2 = self.handles[1]['item'].pos() + p1 = p.transform().map(h1) + p2 = p.transform().map(h2) + + vec = Point(h2) - Point(h1) + length = vec.length() + angle = vec.angle(Point(1, 0)) + + pvec = p2 - p1 + pvecT = Point(pvec.y(), -pvec.x()) + pos = 0.5 * (p1 + p2) + pvecT * 40 / pvecT.length() + + p.resetTransform() + + txt = fn.siFormat(length, suffix='m') + '\n%0.1f deg' % angle + p.drawText(QtCore.QRectF(pos.x()-50, pos.y()-50, 100, 100), QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter, txt) + + def boundingRect(self): + r = LineSegmentROI.boundingRect(self) + pxl = self.pixelLength(Point([1, 0])) + if pxl is None: + return r + pxw = 50 * pxl + return r.adjusted(-50, -50, 50, 50) diff --git a/pyqtgraph/graphicsItems/ScatterPlotItem.py b/pyqtgraph/graphicsItems/ScatterPlotItem.py index 54667b5021..af6efcc891 100644 --- a/pyqtgraph/graphicsItems/ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/ScatterPlotItem.py @@ -5,7 +5,7 @@ imap = map import numpy as np import weakref -from ..Qt import QtGui, QtCore, USE_PYSIDE, USE_PYQT5 +from ..Qt import QtGui, QtCore, QT_LIB from ..Point import Point from .. import functions as fn from .GraphicsItem import GraphicsItem @@ -15,6 +15,7 @@ from .. import debug from ..python2_3 import basestring + __all__ = ['ScatterPlotItem', 'SpotItem'] @@ -122,25 +123,39 @@ def getSymbolCoords(self, opts): """ Given a list of spot records, return an object representing the coordinates of that symbol within the atlas """ - sourceRect = np.empty(len(opts), dtype=object) + + sourceRect = [] keyi = None sourceRecti = None - for i, rec in enumerate(opts): - key = (rec[3], rec[2], id(rec[4]), id(rec[5])) # TODO: use string indexes? + symbol_map = self.symbolMap + + symbols = opts['symbol'].tolist() + sizes = opts['size'].tolist() + pens = opts['pen'].tolist() + brushes = opts['brush'].tolist() + + for symbol, size, pen, brush in zip(symbols, sizes, pens, brushes): + + key = id(symbol), size, id(pen), id(brush) if key == keyi: - sourceRect[i] = sourceRecti + sourceRect.append(sourceRecti) else: try: - sourceRect[i] = self.symbolMap[key] + sourceRect.append(symbol_map[key]) except KeyError: newRectSrc = QtCore.QRectF() - newRectSrc.pen = rec['pen'] - newRectSrc.brush = rec['brush'] - self.symbolMap[key] = newRectSrc + newRectSrc.pen = pen + newRectSrc.brush = brush + newRectSrc.symbol = symbol + + symbol_map[key] = newRectSrc self.atlasValid = False - sourceRect[i] = newRectSrc + sourceRect.append(newRectSrc) + keyi = key sourceRecti = newRectSrc + + sourceRect = np.array(sourceRect, dtype=object) return sourceRect def buildAtlas(self): @@ -151,7 +166,7 @@ def buildAtlas(self): images = [] for key, sourceRect in self.symbolMap.items(): if sourceRect.width() == 0: - img = renderSymbol(key[0], key[1], sourceRect.pen, sourceRect.brush) + img = renderSymbol(sourceRect.symbol, key[1], sourceRect.pen, sourceRect.brush) images.append(img) ## we only need this to prevent the images being garbage collected immediately arr = fn.imageToArray(img, copy=False, transpose=False) else: @@ -251,6 +266,7 @@ def __init__(self, *args, **kargs): 'pxMode': True, 'useCache': True, ## If useCache is False, symbols are re-drawn on every paint. 'antialias': getConfigOption('antialias'), + 'compositionMode': None, 'name': None, } @@ -299,6 +315,8 @@ def setData(self, *args, **kargs): *antialias* Whether to draw symbols with antialiasing. Note that if pxMode is True, symbols are always rendered with antialiasing (since the rendered symbols can be cached, this incurs very little performance cost) + *compositionMode* If specified, this sets the composition mode used when drawing the + scatter plot (see QPainter::CompositionMode in the Qt documentation). *name* The name of this item. Names are used for automatically generating LegendItem entries and by some exporters. ====================== =============================================================================================== @@ -547,6 +565,7 @@ def setPxMode(self, mode): self.invalidate() def updateSpots(self, dataSet=None): + if dataSet is None: dataSet = self.data @@ -597,8 +616,6 @@ def getSpotOpts(self, recs, scale=1.0): recs['brush'][np.equal(recs['brush'], None)] = fn.mkBrush(self.opts['brush']) return recs - - def measureSpotSizes(self, dataSet): for rec in dataSet: ## keep track of the maximum spot size and pixel size @@ -617,7 +634,6 @@ def measureSpotSizes(self, dataSet): self._maxSpotPxWidth = max(self._maxSpotPxWidth, pxWidth) self.bounds = [None, None] - def clear(self): """Remove all spots from the scatter plot""" #self.clearItems() @@ -645,6 +661,9 @@ def dataBounds(self, ax, frac=1.0, orthoRange=None): d = d[mask] d2 = d2[mask] + if d.size == 0: + return (None, None) + if frac >= 1.0: self.bounds[ax] = (np.nanmin(d) - self._maxSpotWidth*0.7072, np.nanmax(d) + self._maxSpotWidth*0.7072) return self.bounds[ax] @@ -697,16 +716,12 @@ def setExportMode(self, *args, **kwds): GraphicsObject.setExportMode(self, *args, **kwds) self.invalidate() - def mapPointsToDevice(self, pts): # Map point locations to device tr = self.deviceTransform() if tr is None: return None - #pts = np.empty((2,len(self.data['x']))) - #pts[0] = self.data['x'] - #pts[1] = self.data['y'] pts = fn.transformCoordinates(tr, pts) pts -= self.data['width'] pts = np.clip(pts, -2**30, 2**30) ## prevent Qt segmentation fault. @@ -727,10 +742,11 @@ def getViewMask(self, pts): (pts[1] - w < viewBounds.bottom())) ## remove out of view points return mask - @debug.warnOnException ## raising an exception here causes crash def paint(self, p, *args): - + cmode = self.opts.get('compositionMode', None) + if cmode is not None: + p.setCompositionMode(cmode) #p.setPen(fn.mkPen('r')) #p.drawRect(self.boundingRect()) @@ -744,42 +760,48 @@ def paint(self, p, *args): if self.opts['pxMode'] is True: p.resetTransform() + data = self.data + # Map point coordinates to device - pts = np.vstack([self.data['x'], self.data['y']]) + pts = np.vstack([data['x'], data['y']]) pts = self.mapPointsToDevice(pts) if pts is None: return # Cull points that are outside view viewMask = self.getViewMask(pts) - #pts = pts[:,mask] - #data = self.data[mask] if self.opts['useCache'] and self._exportOpts is False: # Draw symbols from pre-rendered atlas atlas = self.fragmentAtlas.getAtlas() + target_rect = data['targetRect'] + source_rect = data['sourceRect'] + widths = data['width'] + # Update targetRects if necessary - updateMask = viewMask & np.equal(self.data['targetRect'], None) + updateMask = viewMask & np.equal(target_rect, None) if np.any(updateMask): updatePts = pts[:,updateMask] - width = self.data[updateMask]['width']*2 - self.data['targetRect'][updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width)) - - data = self.data[viewMask] - if USE_PYSIDE or USE_PYQT5: - list(imap(p.drawPixmap, data['targetRect'], repeat(atlas), data['sourceRect'])) + width = widths[updateMask] * 2 + target_rect[updateMask] = list(imap(QtCore.QRectF, updatePts[0,:], updatePts[1,:], width, width)) + + if QT_LIB == 'PyQt4': + p.drawPixmapFragments( + target_rect[viewMask].tolist(), + source_rect[viewMask].tolist(), + atlas + ) else: - p.drawPixmapFragments(data['targetRect'].tolist(), data['sourceRect'].tolist(), atlas) + list(imap(p.drawPixmap, target_rect[viewMask].tolist(), repeat(atlas), source_rect[viewMask].tolist())) else: # render each symbol individually p.setRenderHint(p.Antialiasing, aa) - data = self.data[viewMask] pts = pts[:,viewMask] - for i, rec in enumerate(data): + for i, rec in enumerate(data[viewMask]): p.resetTransform() - p.translate(pts[0,i] + rec['width'], pts[1,i] + rec['width']) + p.translate(pts[0,i] + rec['width']/2, pts[1,i] + rec['width']/2) drawSymbol(p, *self.getSpotOpts(rec, scale)) else: if self.picture is None: @@ -798,9 +820,9 @@ def paint(self, p, *args): self.picture.play(p) def points(self): - for rec in self.data: + for i,rec in enumerate(self.data): if rec['item'] is None: - rec['item'] = SpotItem(rec, self) + rec['item'] = SpotItem(rec, self, i) return self.data['item'] def pointsAt(self, pos): @@ -832,8 +854,8 @@ def mouseClickEvent(self, ev): pts = self.pointsAt(ev.pos()) if len(pts) > 0: self.ptsClicked = pts - self.sigClicked.emit(self, self.ptsClicked) ev.accept() + self.sigClicked.emit(self, self.ptsClicked) else: #print "no spots" ev.ignore() @@ -848,18 +870,26 @@ class SpotItem(object): by connecting to the ScatterPlotItem's click signals. """ - def __init__(self, data, plot): - #GraphicsItem.__init__(self, register=False) + def __init__(self, data, plot, index): self._data = data - self._plot = plot - #self.setParentItem(plot) - #self.setPos(QtCore.QPointF(data['x'], data['y'])) - #self.updateItem() + self._index = index + # SpotItems are kept in plot.data["items"] numpy object array which + # does not support cyclic garbage collection (numpy issue 6581). + # Keeping a strong ref to plot here would leak the cycle + self.__plot_ref = weakref.ref(plot) + + @property + def _plot(self): + return self.__plot_ref() def data(self): """Return the user data associated with this spot.""" return self._data['data'] + def index(self): + """Return the index of this point as given in the scatter plot data.""" + return self._index + def size(self): """Return the size of this spot. If the spot has no explicit size set, then return the ScatterPlotItem's default size instead.""" @@ -943,37 +973,3 @@ def updateItem(self): self._data['sourceRect'] = None self._plot.updateSpots(self._data.reshape(1)) self._plot.invalidate() - -#class PixmapSpotItem(SpotItem, QtGui.QGraphicsPixmapItem): - #def __init__(self, data, plot): - #QtGui.QGraphicsPixmapItem.__init__(self) - #self.setFlags(self.flags() | self.ItemIgnoresTransformations) - #SpotItem.__init__(self, data, plot) - - #def setPixmap(self, pixmap): - #QtGui.QGraphicsPixmapItem.setPixmap(self, pixmap) - #self.setOffset(-pixmap.width()/2.+0.5, -pixmap.height()/2.) - - #def updateItem(self): - #symbolOpts = (self._data['pen'], self._data['brush'], self._data['size'], self._data['symbol']) - - ### If all symbol options are default, use default pixmap - #if symbolOpts == (None, None, -1, ''): - #pixmap = self._plot.defaultSpotPixmap() - #else: - #pixmap = makeSymbolPixmap(size=self.size(), pen=self.pen(), brush=self.brush(), symbol=self.symbol()) - #self.setPixmap(pixmap) - - -#class PathSpotItem(SpotItem, QtGui.QGraphicsPathItem): - #def __init__(self, data, plot): - #QtGui.QGraphicsPathItem.__init__(self) - #SpotItem.__init__(self, data, plot) - - #def updateItem(self): - #QtGui.QGraphicsPathItem.setPath(self, Symbols[self.symbol()]) - #QtGui.QGraphicsPathItem.setPen(self, self.pen()) - #QtGui.QGraphicsPathItem.setBrush(self, self.brush()) - #size = self.size() - #self.resetTransform() - #self.scale(size, size) diff --git a/pyqtgraph/graphicsItems/TargetItem.py b/pyqtgraph/graphicsItems/TargetItem.py new file mode 100644 index 0000000000..114c9e6e2d --- /dev/null +++ b/pyqtgraph/graphicsItems/TargetItem.py @@ -0,0 +1,125 @@ +from ..Qt import QtGui, QtCore +import numpy as np +from ..Point import Point +from .. import functions as fn +from .GraphicsObject import GraphicsObject +from .TextItem import TextItem + + +class TargetItem(GraphicsObject): + """Draws a draggable target symbol (circle plus crosshair). + + The size of TargetItem will remain fixed on screen even as the view is zoomed. + Includes an optional text label. + """ + sigDragged = QtCore.Signal(object) + + def __init__(self, movable=True, radii=(5, 10, 10), pen=(255, 255, 0), brush=(0, 0, 255, 100)): + GraphicsObject.__init__(self) + self._bounds = None + self._radii = radii + self._picture = None + self.movable = movable + self.moving = False + self.label = None + self.labelAngle = 0 + self.pen = fn.mkPen(pen) + self.brush = fn.mkBrush(brush) + + def setLabel(self, label): + if label is None: + if self.label is not None: + self.label.scene().removeItem(self.label) + self.label = None + else: + if self.label is None: + self.label = TextItem() + self.label.setParentItem(self) + self.label.setText(label) + self._updateLabel() + + def setLabelAngle(self, angle): + self.labelAngle = angle + self._updateLabel() + + def boundingRect(self): + if self._picture is None: + self._drawPicture() + return self._bounds + + def dataBounds(self, axis, frac=1.0, orthoRange=None): + return [0, 0] + + def viewTransformChanged(self): + self._picture = None + self.prepareGeometryChange() + self._updateLabel() + + def _updateLabel(self): + if self.label is None: + return + + # find an optimal location for text at the given angle + angle = self.labelAngle * np.pi / 180. + lbr = self.label.boundingRect() + center = lbr.center() + a = abs(np.sin(angle) * lbr.height()*0.5) + b = abs(np.cos(angle) * lbr.width()*0.5) + r = max(self._radii) + 2 + max(a, b) + pos = self.mapFromScene(self.mapToScene(QtCore.QPointF(0, 0)) + r * QtCore.QPointF(np.cos(angle), -np.sin(angle)) - center) + self.label.setPos(pos) + + def paint(self, p, *args): + if self._picture is None: + self._drawPicture() + self._picture.play(p) + + def _drawPicture(self): + self._picture = QtGui.QPicture() + p = QtGui.QPainter(self._picture) + p.setRenderHint(p.Antialiasing) + + # Note: could do this with self.pixelLength, but this is faster. + o = self.mapToScene(QtCore.QPointF(0, 0)) + px = abs(1.0 / (self.mapToScene(QtCore.QPointF(1, 0)) - o).x()) + py = abs(1.0 / (self.mapToScene(QtCore.QPointF(0, 1)) - o).y()) + + r, w, h = self._radii + w = w * px + h = h * py + rx = r * px + ry = r * py + rect = QtCore.QRectF(-rx, -ry, rx*2, ry*2) + p.setPen(self.pen) + p.setBrush(self.brush) + p.drawEllipse(rect) + p.drawLine(Point(-w, 0), Point(w, 0)) + p.drawLine(Point(0, -h), Point(0, h)) + p.end() + + bx = max(w, rx) + by = max(h, ry) + self._bounds = QtCore.QRectF(-bx, -by, bx*2, by*2) + + def mouseDragEvent(self, ev): + if not self.movable: + return + if ev.button() == QtCore.Qt.LeftButton: + if ev.isStart(): + self.moving = True + self.cursorOffset = self.pos() - self.mapToParent(ev.buttonDownPos()) + self.startPosition = self.pos() + ev.accept() + + if not self.moving: + return + + self.setPos(self.cursorOffset + self.mapToParent(ev.pos())) + if ev.isFinish(): + self.moving = False + self.sigDragged.emit(self) + + def hoverEvent(self, ev): + if self.movable: + ev.acceptDrags(QtCore.Qt.LeftButton) + diff --git a/pyqtgraph/graphicsItems/TextItem.py b/pyqtgraph/graphicsItems/TextItem.py index b2587ded51..9dc17960b7 100644 --- a/pyqtgraph/graphicsItems/TextItem.py +++ b/pyqtgraph/graphicsItems/TextItem.py @@ -110,9 +110,16 @@ def setFont(self, *args): self.updateTextPos() def setAngle(self, angle): + """ + Set the angle of the text in degrees. + + This sets the rotation angle of the text as a whole, measured + counter-clockwise from the x axis of the parent. Note that this rotation + angle does not depend on horizontal/vertical scaling of the parent. + """ self.angle = angle - self.updateTransform() - + self.updateTransform(force=True) + def setAnchor(self, anchor): self.anchor = Point(anchor) self.updateTextPos() @@ -169,7 +176,7 @@ def paint(self, p, *args): p.setRenderHint(p.Antialiasing, True) p.drawPolygon(self.textItem.mapToParent(self.textItem.boundingRect())) - def updateTransform(self): + def updateTransform(self, force=False): # update transform such that this item has the correct orientation # and scaling relative to the scene, but inherits its position from its # parent. @@ -181,7 +188,7 @@ def updateTransform(self): else: pt = p.sceneTransform() - if pt == self._lastTransform: + if not force and pt == self._lastTransform: return t = pt.inverted()[0] diff --git a/pyqtgraph/graphicsItems/UIGraphicsItem.py b/pyqtgraph/graphicsItems/UIGraphicsItem.py index 6f756334a1..2074a2e9a7 100644 --- a/pyqtgraph/graphicsItems/UIGraphicsItem.py +++ b/pyqtgraph/graphicsItems/UIGraphicsItem.py @@ -1,7 +1,7 @@ -from ..Qt import QtGui, QtCore, USE_PYSIDE +from ..Qt import QtGui, QtCore, QT_LIB import weakref from .GraphicsObject import GraphicsObject -if not USE_PYSIDE: +if QT_LIB in ['PyQt4', 'PyQt5']: import sip __all__ = ['UIGraphicsItem'] @@ -49,7 +49,7 @@ def itemChange(self, change, value): ## workaround for pyqt bug: ## http://www.riverbankcomputing.com/pipermail/pyqt/2012-August/031818.html - if not USE_PYSIDE and change == self.ItemParentChange and isinstance(ret, QtGui.QGraphicsItem): + if QT_LIB in ['PyQt4', 'PyQt5'] and change == self.ItemParentChange and isinstance(ret, QtGui.QGraphicsItem): ret = sip.cast(ret, QtGui.QGraphicsItem) if change == self.ItemScenePositionHasChanged: diff --git a/pyqtgraph/graphicsItems/VTickGroup.py b/pyqtgraph/graphicsItems/VTickGroup.py index 1db4a4a26a..2b4f256fc8 100644 --- a/pyqtgraph/graphicsItems/VTickGroup.py +++ b/pyqtgraph/graphicsItems/VTickGroup.py @@ -90,7 +90,7 @@ def paint(self, p, *args): br = self.boundingRect() h = br.height() br.setY(br.y() + self.yrange[0] * h) - br.setHeight(h - (1.0-self.yrange[1]) * h) + br.setHeight((self.yrange[1] - self.yrange[0]) * h) p.translate(0, br.y()) p.scale(1.0, br.height()) p.setPen(self.pen) diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index 4cab8662a0..e665deefd6 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -1,9 +1,10 @@ +# -*- coding: utf-8 -*- import weakref import sys from copy import deepcopy import numpy as np from ...Qt import QtGui, QtCore -from ...python2_3 import sortList, basestring, cmp +from ...python2_3 import basestring from ...Point import Point from ... import functions as fn from .. ItemGroup import ItemGroup @@ -14,6 +15,7 @@ __all__ = ['ViewBox'] + class WeakList(object): def __init__(self): @@ -34,22 +36,24 @@ def __iter__(self): yield d i -= 1 + class ChildGroup(ItemGroup): - + def __init__(self, parent): ItemGroup.__init__(self, parent) - - # Used as callback to inform ViewBox when items are added/removed from - # the group. - # Note 1: We would prefer to override itemChange directly on the + self.setFlag(self.ItemClipsChildrenToShape) + + # Used as callback to inform ViewBox when items are added/removed from + # the group. + # Note 1: We would prefer to override itemChange directly on the # ViewBox, but this causes crashes on PySide. # Note 2: We might also like to use a signal rather than this callback - # mechanism, but this causes a different PySide crash. + # mechanism, but this causes a different PySide crash. self.itemsChangedListeners = WeakList() - + # excempt from telling view when transform changes self._GraphicsObject__inform_view_on_change = False - + def itemChange(self, change, value): ret = ItemGroup.itemChange(self, change, value) if change == self.ItemChildAddedChange or change == self.ItemChildRemovedChange: @@ -64,45 +68,50 @@ def itemChange(self, change, value): listener.itemsChanged() return ret + def shape(self): + return self.mapFromParent(self.parentItem().shape()) + + def boundingRect(self): + return self.mapRectFromParent(self.parentItem().boundingRect()) + class ViewBox(GraphicsWidget): """ **Bases:** :class:`GraphicsWidget ` - - Box that allows internal scaling/panning of children by mouse drag. + + Box that allows internal scaling/panning of children by mouse drag. This class is usually created automatically as part of a :class:`PlotItem ` or :class:`Canvas ` or with :func:`GraphicsLayout.addViewBox() `. - + Features: - + * Scaling contents by mouse or auto-scale when contents change * View linking--multiple views display the same data ranges * Configurable by context menu * Item coordinate mapping methods - + """ - + sigYRangeChanged = QtCore.Signal(object, object) sigXRangeChanged = QtCore.Signal(object, object) sigRangeChangedManually = QtCore.Signal(object) sigRangeChanged = QtCore.Signal(object, object) - #sigActionPositionChanged = QtCore.Signal(object) sigStateChanged = QtCore.Signal(object) sigTransformChanged = QtCore.Signal(object) sigResized = QtCore.Signal(object) - + ## mouse modes PanMode = 3 RectMode = 1 - + ## axes XAxis = 0 YAxis = 1 XYAxes = 2 - + ## for linking views together NamedViews = weakref.WeakValueDictionary() # name: ViewBox AllViews = weakref.WeakKeyDictionary() # ViewBox: None - + def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, invertY=False, enableMenu=True, name=None, invertX=False): """ ============== ============================================================= @@ -115,82 +124,84 @@ def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, *enableMouse* (bool) Whether mouse can be used to scale/pan the view *invertY* (bool) See :func:`invertY ` *invertX* (bool) See :func:`invertX ` - *enableMenu* (bool) Whether to display a context menu when + *enableMenu* (bool) Whether to display a context menu when right-clicking on the ViewBox background. *name* (str) Used to register this ViewBox so that it appears in the "Link axis" dropdown inside other ViewBox context menus. This allows the user to manually link - the axes of any other view to this one. + the axes of any other view to this one. ============== ============================================================= """ - + GraphicsWidget.__init__(self, parent) self.name = None self.linksBlocked = False self.addedItems = [] - #self.gView = view - #self.showGrid = showGrid self._matrixNeedsUpdate = True ## indicates that range has changed, but matrix update was deferred self._autoRangeNeedsUpdate = True ## indicates auto-range needs to be recomputed. self._lastScene = None ## stores reference to the last known scene this view was a part of. - + self.state = { - + ## separating targetRange and viewRange allows the view to be resized ## while keeping all previously viewed contents visible 'targetRange': [[0,1], [0,1]], ## child coord. range visible [[xmin, xmax], [ymin, ymax]] 'viewRange': [[0,1], [0,1]], ## actual range viewed - + 'yInverted': invertY, 'xInverted': invertX, 'aspectLocked': False, ## False if aspect is unlocked, otherwise float specifies the locked ratio. - 'autoRange': [True, True], ## False if auto range is disabled, + 'autoRange': [True, True], ## False if auto range is disabled, ## otherwise float gives the fraction of data that is visible 'autoPan': [False, False], ## whether to only pan (do not change scaling) when auto-range is enabled - 'autoVisibleOnly': [False, False], ## whether to auto-range only to the visible portion of a plot + 'autoVisibleOnly': [False, False], ## whether to auto-range only to the visible portion of a plot 'linkedViews': [None, None], ## may be None, "viewName", or weakref.ref(view) ## a name string indicates that the view *should* link to another, but no view with that name exists yet. - + 'mouseEnabled': [enableMouse, enableMouse], - 'mouseMode': ViewBox.PanMode if getConfigOption('leftButtonPan') else ViewBox.RectMode, + 'mouseMode': ViewBox.PanMode if getConfigOption('leftButtonPan') else ViewBox.RectMode, 'enableMenu': enableMenu, 'wheelScaleFactor': -1.0 / 8.0, 'background': None, - + # Limits 'limits': { - 'xLimits': [None, None], # Maximum and minimum visible X values - 'yLimits': [None, None], # Maximum and minimum visible Y values + 'xLimits': [None, None], # Maximum and minimum visible X values + 'yLimits': [None, None], # Maximum and minimum visible Y values 'xRange': [None, None], # Maximum and minimum X range - 'yRange': [None, None], # Maximum and minimum Y range + 'yRange': [None, None], # Maximum and minimum Y range } - + } self._updatingRange = False ## Used to break recursive loops. See updateAutoRange. self._itemBoundsCache = weakref.WeakKeyDictionary() - + self.locateGroup = None ## items displayed when using ViewBox.locate(item) - + self.setFlag(self.ItemClipsChildrenToShape) self.setFlag(self.ItemIsFocusable, True) ## so we can receive key presses - + ## childGroup is required so that ViewBox has local coordinates similar to device coordinates. ## this is a workaround for a Qt + OpenGL bug that causes improper clipping ## https://bugreports.qt.nokia.com/browse/QTBUG-23723 self.childGroup = ChildGroup(self) self.childGroup.itemsChangedListeners.append(self) - + self.background = QtGui.QGraphicsRectItem(self.rect()) self.background.setParentItem(self) self.background.setZValue(-1e6) self.background.setPen(fn.mkPen(None)) self.updateBackground() - - #self.useLeftButtonPan = pyqtgraph.getConfigOption('leftButtonPan') # normally use left button to pan - # this also enables capture of keyPressEvents. - + + self.border = fn.mkPen(border) + + self.borderRect = QtGui.QGraphicsRectItem(self.rect()) + self.borderRect.setParentItem(self) + self.borderRect.setZValue(1e3) + self.borderRect.setPen(self.border) + ## Make scale box that is shown when dragging on the view self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) self.rbScaleBox.setPen(fn.mkPen((255,255,100), width=1)) @@ -198,36 +209,49 @@ def __init__(self, parent=None, border=None, lockAspect=False, enableMouse=True, self.rbScaleBox.setZValue(1e9) self.rbScaleBox.hide() self.addItem(self.rbScaleBox, ignoreBounds=True) - + ## show target rect for debugging self.target = QtGui.QGraphicsRectItem(0, 0, 1, 1) self.target.setPen(fn.mkPen('r')) self.target.setParentItem(self) self.target.hide() - + self.axHistory = [] # maintain a history of zoom locations self.axHistoryPointer = -1 # pointer into the history. Allows forward/backward movement, not just "undo" - + self.setZValue(-100) self.setSizePolicy(QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding)) - + self.setAspectLocked(lockAspect) - - self.border = fn.mkPen(border) - self.menu = ViewBoxMenu(self) - + + if enableMenu: + self.menu = ViewBoxMenu(self) + else: + self.menu = None + self.register(name) if name is None: self.updateViewLists() - + + def getAspectRatio(self): + '''return the current aspect ratio''' + rect = self.rect() + vr = self.viewRect() + if rect.height() == 0 or vr.width() == 0 or vr.height() == 0: + currentRatio = 1.0 + else: + currentRatio = (rect.width()/float(rect.height())) / ( + vr.width()/vr.height()) + return currentRatio + def register(self, name): """ - Add this ViewBox to the registered list of views. - + Add this ViewBox to the registered list of views. + This allows users to manually link the axes of any other ViewBox to - this one. The specified *name* will appear in the drop-down lists for + this one. The specified *name* will appear in the drop-down lists for axis linking in the context menus of all other views. - + The same can be accomplished by initializing the ViewBox with the *name* attribute. """ ViewBox.AllViews[self] = None @@ -239,7 +263,6 @@ def register(self, name): ViewBox.updateAllViewLists() sid = id(self) self.destroyed.connect(lambda: ViewBox.forgetView(sid, name) if (ViewBox is not None and 'sid' in locals() and 'name' in locals()) else None) - #self.destroyed.connect(self.unregister) def unregister(self): """ @@ -255,7 +278,7 @@ def close(self): def implements(self, interface): return interface == 'ViewBox' - + # removed due to https://bugreports.qt-project.org/browse/PYSIDE-86 #def itemChange(self, change, value): ## Note: Calling QWidget.itemChange causes segv in python 3 + PyQt @@ -270,37 +293,20 @@ def implements(self, interface): #if scene is not None and hasattr(scene, 'sigPrepareForPaint'): #scene.sigPrepareForPaint.connect(self.prepareForPaint) #return ret - - def checkSceneChange(self): - # ViewBox needs to receive sigPrepareForPaint from its scene before - # being painted. However, we have no way of being informed when the - # scene has changed in order to make this connection. The usual way - # to do this is via itemChange(), but bugs prevent this approach - # (see above). Instead, we simply check at every paint to see whether - # (the scene has changed. - scene = self.scene() - if scene == self._lastScene: - return - if self._lastScene is not None and hasattr(self.lastScene, 'sigPrepareForPaint'): - self._lastScene.sigPrepareForPaint.disconnect(self.prepareForPaint) - if scene is not None and hasattr(scene, 'sigPrepareForPaint'): - scene.sigPrepareForPaint.connect(self.prepareForPaint) + + def update(self, *args, **kwargs): self.prepareForPaint() - self._lastScene = scene - - - + GraphicsWidget.update(self, *args, **kwargs) def prepareForPaint(self): #autoRangeEnabled = (self.state['autoRange'][0] is not False) or (self.state['autoRange'][1] is not False) # don't check whether auto range is enabled here--only check when setting dirty flag. - if self._autoRangeNeedsUpdate: # and autoRangeEnabled: + if self._autoRangeNeedsUpdate: # and autoRangeEnabled: self.updateAutoRange() - if self._matrixNeedsUpdate: - self.updateMatrix() - + self.updateMatrix() + def getState(self, copy=True): - """Return the current state of the ViewBox. + """Return the current state of the ViewBox. Linked views are always converted to view names in the returned state.""" state = self.state.copy() views = [] @@ -316,7 +322,7 @@ def getState(self, copy=True): return deepcopy(state) else: return state - + def setState(self, state): """Restore the state of this ViewBox. (see also getState)""" @@ -324,18 +330,19 @@ def setState(self, state): self.setXLink(state['linkedViews'][0]) self.setYLink(state['linkedViews'][1]) del state['linkedViews'] - + self.state.update(state) - #self.updateMatrix() + + self._applyMenuEnabled() self.updateViewRange() self.sigStateChanged.emit(self) def setBackgroundColor(self, color): """ Set the background color of the ViewBox. - + If color is None, then no background will be drawn. - + Added in version 0.9.9 """ self.background.setVisible(color is not None) @@ -353,12 +360,6 @@ def setMouseMode(self, mode): self.state['mouseMode'] = mode self.sigStateChanged.emit(self) - #def toggleLeftAction(self, act): ## for backward compatibility - #if act.text() is 'pan': - #self.setLeftButtonAction('pan') - #elif act.text() is 'zoom': - #self.setLeftButtonAction('rect') - def setLeftButtonAction(self, mode='rect'): ## for backward compatibility if mode.lower() == 'rect': self.setMouseMode(ViewBox.RectMode) @@ -366,10 +367,10 @@ def setLeftButtonAction(self, mode='rect'): ## for backward compatibility self.setMouseMode(ViewBox.PanMode) else: raise Exception('graphicsItems:ViewBox:setLeftButtonAction: unknown mode = %s (Options are "pan" and "rect")' % mode) - + def innerSceneItem(self): return self.childGroup - + def setMouseEnabled(self, x=None, y=None): """ Set whether each axis is enabled for mouse interaction. *x*, *y* arguments must be True or False. @@ -380,17 +381,27 @@ def setMouseEnabled(self, x=None, y=None): if y is not None: self.state['mouseEnabled'][1] = y self.sigStateChanged.emit(self) - + def mouseEnabled(self): return self.state['mouseEnabled'][:] - + def setMenuEnabled(self, enableMenu=True): self.state['enableMenu'] = enableMenu + self._applyMenuEnabled() self.sigStateChanged.emit(self) def menuEnabled(self): - return self.state.get('enableMenu', True) - + return self.state.get('enableMenu', True) + + def _applyMenuEnabled(self): + enableMenu = self.state.get("enableMenu", True) + if enableMenu and self.menu is None: + self.menu = ViewBoxMenu(self) + self.updateViewLists() + elif not enableMenu and self.menu is not None: + self.menu.setParent(None) + self.menu = None + def addItem(self, item, ignoreBounds=False): """ Add a QGraphicsItem to this view. The view will include this item when determining how to set its range @@ -398,22 +409,28 @@ def addItem(self, item, ignoreBounds=False): """ if item.zValue() < self.zValue(): item.setZValue(self.zValue()+1) + scene = self.scene() if scene is not None and scene is not item.scene(): scene.addItem(item) ## Necessary due to Qt bug: https://bugreports.qt-project.org/browse/QTBUG-18616 item.setParentItem(self.childGroup) + if not ignoreBounds: self.addedItems.append(item) self.updateAutoRange() - #print "addItem:", item, item.boundingRect() - + def removeItem(self, item): """Remove an item from this view.""" try: self.addedItems.remove(item) except: pass - self.scene().removeItem(item) + + scene = self.scene() + if scene is not None: + scene.removeItem(item) + item.setParentItem(None) + self.updateAutoRange() def clear(self): @@ -421,17 +438,27 @@ def clear(self): self.removeItem(i) for ch in self.childGroup.childItems(): ch.setParentItem(None) - + def resizeEvent(self, ev): + self._matrixNeedsUpdate = True + self.updateMatrix() + self.linkedXChanged() self.linkedYChanged() + self.updateAutoRange() self.updateViewRange() + self._matrixNeedsUpdate = True - self.sigStateChanged.emit(self) + self.updateMatrix() + self.background.setRect(self.rect()) + self.borderRect.setRect(self.rect()) + + self.sigStateChanged.emit(self) self.sigResized.emit(self) - + self.childGroup.prepareGeometryChange() + def viewRange(self): """Return a the view's visible range as a list: [[xmin, xmax], [ymin, ymax]]""" return [x[:] for x in self.state['viewRange']] ## return copy @@ -445,13 +472,13 @@ def viewRect(self): except: print("make qrectf failed:", self.state['viewRange']) raise - + def targetRange(self): return [x[:] for x in self.state['targetRange']] ## return copy - - def targetRect(self): + + def targetRect(self): """ - Return the region which has been requested to be visible. + Return the region which has been requested to be visible. (this is not necessarily the same as the region that is *actually* visible-- resizing and aspect ratio constraints can cause targetRect() and viewRect() to differ) """ @@ -473,30 +500,30 @@ def _resetTarget(self): def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=True, disableAutoRange=True): """ Set the visible range of the ViewBox. - Must specify at least one of *rect*, *xRange*, or *yRange*. - + Must specify at least one of *rect*, *xRange*, or *yRange*. + ================== ===================================================================== **Arguments:** *rect* (QRectF) The full range that should be visible in the view box. *xRange* (min,max) The range that should be visible along the x-axis. *yRange* (min,max) The range that should be visible along the y-axis. - *padding* (float) Expand the view by a fraction of the requested range. + *padding* (float) Expand the view by a fraction of the requested range. By default, this value is set between 0.02 and 0.1 depending on the size of the ViewBox. - *update* (bool) If True, update the range of the ViewBox immediately. + *update* (bool) If True, update the range of the ViewBox immediately. Otherwise, the update is deferred until before the next render. *disableAutoRange* (bool) If True, auto-ranging is diabled. Otherwise, it is left unchanged. ================== ===================================================================== - + """ #print self.name, "ViewBox.setRange", rect, xRange, yRange, padding #import traceback #traceback.print_stack() - + changes = {} # axes setRequested = [False, False] - + if rect is not None: changes = {0: [rect.left(), rect.right()], 1: [rect.top(), rect.bottom()]} setRequested = [True, True] @@ -510,27 +537,39 @@ def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=Tru if len(changes) == 0: print(rect) raise Exception("Must specify at least one of rect, xRange, or yRange. (gave rect=%s)" % str(type(rect))) - + # Update axes one at a time changed = [False, False] + + # Disable auto-range for each axis that was requested to be set + if disableAutoRange: + xOff = False if setRequested[0] else None + yOff = False if setRequested[1] else None + self.enableAutoRange(x=xOff, y=yOff) + changed.append(True) + + limits = (self.state['limits']['xLimits'], self.state['limits']['yLimits']) + minRng = [self.state['limits']['xRange'][0], self.state['limits']['yRange'][0]] + maxRng = [self.state['limits']['xRange'][1], self.state['limits']['yRange'][1]] + for ax, range in changes.items(): mn = min(range) mx = max(range) - - # If we requested 0 range, try to preserve previous scale. + + # If we requested 0 range, try to preserve previous scale. # Otherwise just pick an arbitrary scale. - if mn == mx: + if mn == mx: dy = self.state['viewRange'][ax][1] - self.state['viewRange'][ax][0] if dy == 0: dy = 1 mn -= dy*0.5 mx += dy*0.5 xpad = 0.0 - + # Make sure no nan/inf get through if not all(np.isfinite([mn, mx])): raise Exception("Cannot set range [%s, %s]" % (str(mn), str(mx))) - + # Apply padding if padding is None: xpad = self.suggestPadding(ax) @@ -539,74 +578,79 @@ def setRange(self, rect=None, xRange=None, yRange=None, padding=None, update=Tru p = (mx-mn) * xpad mn -= p mx += p - + + # max range cannot be larger than bounds, if they are given + if limits[ax][0] is not None and limits[ax][1] is not None: + if maxRng[ax] is not None: + maxRng[ax] = min(maxRng[ax], limits[ax][1] - limits[ax][0]) + else: + maxRng[ax] = limits[ax][1] - limits[ax][0] + + # If we have limits, we will have at least a max range as well + if maxRng[ax] is not None or minRng[ax] is not None: + diff = mx - mn + if maxRng[ax] is not None and diff > maxRng[ax]: + delta = maxRng[ax] - diff + elif minRng[ax] is not None and diff < minRng[ax]: + delta = minRng[ax] - diff + else: + delta = 0 + + mn -= delta / 2. + mx += delta / 2. + + # Make sure our requested area is within limits, if any + if limits[ax][0] is not None or limits[ax][1] is not None: + lmn, lmx = limits[ax] + if lmn is not None and mn < lmn: + delta = lmn - mn # Shift the requested view to match our lower limit + mn = lmn + mx += delta + elif lmx is not None and mx > lmx: + delta = lmx - mx + mx = lmx + mn += delta + + # Set target range if self.state['targetRange'][ax] != [mn, mx]: self.state['targetRange'][ax] = [mn, mx] changed[ax] = True - - # Update viewRange to match targetRange as closely as possible while + + # Update viewRange to match targetRange as closely as possible while # accounting for aspect ratio constraint lockX, lockY = setRequested if lockX and lockY: lockX = False lockY = False self.updateViewRange(lockX, lockY) - - # Disable auto-range for each axis that was requested to be set - if disableAutoRange: - xOff = False if setRequested[0] else None - yOff = False if setRequested[1] else None - self.enableAutoRange(x=xOff, y=yOff) - changed.append(True) # If nothing has changed, we are done. if any(changed): - #if update and self.matrixNeedsUpdate: - #self.updateMatrix(changed) - #return - - self.sigStateChanged.emit(self) - # Update target rect for debugging if self.target.isVisible(): self.target.setRect(self.mapRectFromItem(self.childGroup, self.targetRect())) - - # If ortho axes have auto-visible-only, update them now - # Note that aspect ratio constraints and auto-visible probably do not work together.. - if changed[0] and self.state['autoVisibleOnly'][1] and (self.state['autoRange'][0] is not False): - self._autoRangeNeedsUpdate = True - #self.updateAutoRange() ## Maybe just indicate that auto range needs to be updated? - elif changed[1] and self.state['autoVisibleOnly'][0] and (self.state['autoRange'][1] is not False): - self._autoRangeNeedsUpdate = True - #self.updateAutoRange() - - ## Update view matrix only if requested - #if update: - #self.updateMatrix(changed) - ## Otherwise, indicate that the matrix needs to be updated - #else: - #self.matrixNeedsUpdate = True - - ## Inform linked views that the range has changed <> - #for ax, range in changes.items(): - #link = self.linkedView(ax) - #if link is not None: - #link.linkedViewChanged(self, ax) - - - + + # If ortho axes have auto-visible-only, update them now + # Note that aspect ratio constraints and auto-visible probably do not work together.. + if changed[0] and self.state['autoVisibleOnly'][1] and (self.state['autoRange'][0] is not False): + self._autoRangeNeedsUpdate = True + elif changed[1] and self.state['autoVisibleOnly'][0] and (self.state['autoRange'][1] is not False): + self._autoRangeNeedsUpdate = True + + self.sigStateChanged.emit(self) + def setYRange(self, min, max, padding=None, update=True): """ - Set the visible Y range of the view to [*min*, *max*]. + Set the visible Y range of the view to [*min*, *max*]. The *padding* argument causes the range to be set larger by the fraction specified. (by default, this value is between 0.02 and 0.1 depending on the size of the ViewBox) """ self.setRange(yRange=[min, max], update=update, padding=padding) - + def setXRange(self, min, max, padding=None, update=True): """ - Set the visible X range of the view to [*min*, *max*]. + Set the visible X range of the view to [*min*, *max*]. The *padding* argument causes the range to be set larger by the fraction specified. (by default, this value is between 0.02 and 0.1 depending on the size of the ViewBox) """ @@ -615,9 +659,9 @@ def setXRange(self, min, max, padding=None, update=True): def autoRange(self, padding=None, items=None, item=None): """ Set the range of the view box to make all children visible. - Note that this is not the same as enableAutoRange, which causes the view to + Note that this is not the same as enableAutoRange, which causes the view to automatically auto-range whenever its contents are changed. - + ============== ============================================================ **Arguments:** padding The fraction of the total data range to add on to the final @@ -632,10 +676,10 @@ def autoRange(self, padding=None, items=None, item=None): else: print("Warning: ViewBox.autoRange(item=__) is deprecated. Use 'items' argument instead.") bounds = self.mapFromItemToView(item, item.boundingRect()).boundingRect() - + if bounds is not None: self.setRange(bounds, padding=padding) - + def suggestPadding(self, axis): l = self.width() if axis==0 else self.height() if l > 0: @@ -643,31 +687,31 @@ def suggestPadding(self, axis): else: padding = 0.02 return padding - + def setLimits(self, **kwds): """ Set limits that constrain the possible view ranges. - - **Panning limits**. The following arguments define the region within the + + **Panning limits**. The following arguments define the region within the viewbox coordinate system that may be accessed by panning the view. - + =========== ============================================================ xMin Minimum allowed x-axis value xMax Maximum allowed x-axis value yMin Minimum allowed y-axis value yMax Maximum allowed y-axis value - =========== ============================================================ - + =========== ============================================================ + **Scaling limits**. These arguments prevent the view being zoomed in or out too far. - + =========== ============================================================ minXRange Minimum allowed left-to-right span across the view. maxXRange Maximum allowed left-to-right span across the view. minYRange Minimum allowed top-to-bottom span across the view. maxYRange Maximum allowed top-to-bottom span across the view. =========== ============================================================ - + Added in version 0.9.9 """ update = False @@ -675,10 +719,6 @@ def setLimits(self, **kwds): for kwd in kwds: if kwd not in allowed: raise ValueError("Invalid keyword argument '%s'." % kwd) - #for kwd in ['xLimits', 'yLimits', 'minRange', 'maxRange']: - #if kwd in kwds and self.state['limits'][kwd] != kwds[kwd]: - #self.state['limits'][kwd] = kwds[kwd] - #update = True for axis in [0,1]: for mnmx in [0,1]: kwd = [['xMin', 'xMax'], ['yMin', 'yMax']][axis][mnmx] @@ -691,39 +731,28 @@ def setLimits(self, **kwds): if kwd in kwds and self.state['limits'][lname][mnmx] != kwds[kwd]: self.state['limits'][lname][mnmx] = kwds[kwd] update = True - + if update: self.updateViewRange() - - - - + def scaleBy(self, s=None, center=None, x=None, y=None): """ Scale by *s* around given center point (or center of view). *s* may be a Point or tuple (x, y). - - Optionally, x or y may be specified individually. This allows the other + + Optionally, x or y may be specified individually. This allows the other axis to be left unaffected (note that using a scale factor of 1.0 may cause slight changes due to floating-point error). """ if s is not None: - scale = Point(s) - else: - scale = [x, y] - - affect = [True, True] - if scale[0] is None and scale[1] is None: + x, y = s[0], s[1] + + affect = [x is not None, y is not None] + if not any(affect): return - elif scale[0] is None: - affect[0] = False - scale[0] = 1.0 - elif scale[1] is None: - affect[1] = False - scale[1] = 1.0 - - scale = Point(scale) - + + scale = Point([1.0 if x is None else x, 1.0 if y is None else y]) + if self.state['aspectLocked'] is not False: scale[0] = scale[1] @@ -732,21 +761,21 @@ def scaleBy(self, s=None, center=None, x=None, y=None): center = Point(vr.center()) else: center = Point(center) - + tl = center + (vr.topLeft()-center) * scale br = center + (vr.bottomRight()-center) * scale - + if not affect[0]: self.setYRange(tl.y(), br.y(), padding=0) elif not affect[1]: self.setXRange(tl.x(), br.x(), padding=0) else: self.setRange(QtCore.QRectF(tl, br), padding=0) - + def translateBy(self, t=None, x=None, y=None): """ Translate the view by *t*, which may be a Point or tuple (x, y). - + Alternately, x or y may be specified independently, leaving the other axis unchanged (note that using a translation of 0 may still cause small changes due to floating-point error). @@ -762,9 +791,7 @@ def translateBy(self, t=None, x=None, y=None): y = vr.top()+y, vr.bottom()+y if x is not None or y is not None: self.setRange(xRange=x, yRange=y, padding=0) - - - + def enableAutoRange(self, axis=None, enable=True, x=None, y=None): """ Enable (or disable) auto-range for *axis*, which may be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes for both @@ -773,11 +800,6 @@ def enableAutoRange(self, axis=None, enable=True, x=None, y=None): The argument *enable* may optionally be a float (0.0-1.0) which indicates the fraction of the data that should be visible (this only works with items implementing a dataRange method, such as PlotDataItem). """ - #print "autorange:", axis, enable - #if not enable: - #import traceback - #traceback.print_stack() - # support simpler interface: if x is not None or y is not None: if x is not None: @@ -785,15 +807,15 @@ def enableAutoRange(self, axis=None, enable=True, x=None, y=None): if y is not None: self.enableAutoRange(ViewBox.YAxis, y) return - + if enable is True: enable = 1.0 - + if axis is None: axis = ViewBox.XYAxes - + needAutoRangeUpdate = False - + if axis == ViewBox.XYAxes or axis == 'xy': axes = [0, 1] elif axis == ViewBox.XAxis or axis == 'x': @@ -802,22 +824,18 @@ def enableAutoRange(self, axis=None, enable=True, x=None, y=None): axes = [1] else: raise Exception('axis argument must be ViewBox.XAxis, ViewBox.YAxis, or ViewBox.XYAxes.') - + for ax in axes: if self.state['autoRange'][ax] != enable: # If we are disabling, do one last auto-range to make sure that # previously scheduled auto-range changes are enacted if enable is False and self._autoRangeNeedsUpdate: self.updateAutoRange() - + self.state['autoRange'][ax] = enable self._autoRangeNeedsUpdate |= (enable is not False) self.update() - - #if needAutoRangeUpdate: - # self.updateAutoRange() - self.sigStateChanged.emit(self) def disableAutoRange(self, axis=None): @@ -828,6 +846,8 @@ def autoRangeEnabled(self): return self.state['autoRange'][:] def setAutoPan(self, x=None, y=None): + """Set whether automatic range will only pan (not scale) the view. + """ if x is not None: self.state['autoPan'][0] = x if y is not None: @@ -836,6 +856,9 @@ def setAutoPan(self, x=None, y=None): self.updateAutoRange() def setAutoVisible(self, x=None, y=None): + """Set whether automatic range uses only visible data when determining + the range to show. + """ if x is not None: self.state['autoVisibleOnly'][0] = x if x is True: @@ -844,30 +867,30 @@ def setAutoVisible(self, x=None, y=None): self.state['autoVisibleOnly'][1] = y if y is True: self.state['autoVisibleOnly'][0] = False - + if x is not None or y is not None: self.updateAutoRange() def updateAutoRange(self): ## Break recursive loops when auto-ranging. - ## This is needed because some items change their size in response + ## This is needed because some items change their size in response ## to a view change. if self._updatingRange: return - + self._updatingRange = True try: targetRect = self.viewRange() if not any(self.state['autoRange']): return - + fractionVisible = self.state['autoRange'][:] for i in [0,1]: if type(fractionVisible[i]) is bool: fractionVisible[i] = 1.0 childRange = None - + order = [0,1] if self.state['autoVisibleOnly'][0] is True: order = [1,0] @@ -880,11 +903,11 @@ def updateAutoRange(self): oRange = [None, None] oRange[ax] = targetRect[1-ax] childRange = self.childrenBounds(frac=fractionVisible, orthoRange=oRange) - + else: if childRange is None: childRange = self.childrenBounds(frac=fractionVisible) - + ## Make corrections to range xr = childRange[ax] if xr is not None: @@ -899,32 +922,32 @@ def updateAutoRange(self): childRange[ax][1] += wp targetRect[ax] = childRange[ax] args['xRange' if ax == 0 else 'yRange'] = targetRect[ax] - if len(args) == 0: - return - args['padding'] = 0 - args['disableAutoRange'] = False - + # check for and ignore bad ranges for k in ['xRange', 'yRange']: if k in args: if not np.all(np.isfinite(args[k])): r = args.pop(k) #print("Warning: %s is invalid: %s" % (k, str(r)) - + + if len(args) == 0: + return + args['padding'] = 0 + args['disableAutoRange'] = False + self.setRange(**args) finally: self._autoRangeNeedsUpdate = False self._updatingRange = False - + def setXLink(self, view): """Link this view's X axis to another view. (see LinkView)""" self.linkView(self.XAxis, view) - + def setYLink(self, view): """Link this view's Y axis to another view. (see LinkView)""" self.linkView(self.YAxis, view) - - + def linkView(self, axis, view): """ Link X or Y axes of two views and unlink any previously connected axes. *axis* must be ViewBox.XAxis or ViewBox.YAxis. @@ -956,8 +979,8 @@ def linkView(self, axis, view): except (TypeError, RuntimeError): ## This can occur if the view has been deleted already pass - - + + if view is None or isinstance(view, basestring): self.state['linkedViews'][axis] = view else: @@ -970,10 +993,10 @@ def linkView(self, axis, view): else: if self.autoRangeEnabled()[axis] is False: slot() - - + + self.sigStateChanged.emit(self) - + def blockLink(self, b): self.linksBlocked = b ## prevents recursive plot-change propagation @@ -986,7 +1009,7 @@ def linkedYChanged(self): ## called when y range of linked view has changed view = self.linkedView(1) self.linkedViewChanged(view, ViewBox.YAxis) - + def linkedView(self, ax): ## Return the linked view for axis *ax*. ## this method _always_ returns either a ViewBox or None. @@ -999,19 +1022,19 @@ def linkedView(self, ax): def linkedViewChanged(self, view, axis): if self.linksBlocked or view is None: return - + #print self.name, "ViewBox.linkedViewChanged", axis, view.viewRange()[axis] vr = view.viewRect() vg = view.screenGeometry() sg = self.screenGeometry() if vg is None or sg is None: return - + view.blockLink(True) try: if axis == ViewBox.XAxis: overlap = min(sg.right(), vg.right()) - max(sg.left(), vg.left()) - if overlap < min(vg.width()/3, sg.width()/3): ## if less than 1/3 of views overlap, + if overlap < min(vg.width()/3, sg.width()/3): ## if less than 1/3 of views overlap, ## then just replicate the view x1 = vr.left() x2 = vr.right() @@ -1026,7 +1049,7 @@ def linkedViewChanged(self, view, axis): self.setXRange(x1, x2, padding=0) else: overlap = min(sg.bottom(), vg.bottom()) - max(sg.top(), vg.top()) - if overlap < min(vg.height()/3, sg.height()/3): ## if less than 1/3 of views overlap, + if overlap < min(vg.height()/3, sg.height()/3): ## if less than 1/3 of views overlap, ## then just replicate the view y1 = vr.top() y2 = vr.bottom() @@ -1041,7 +1064,7 @@ def linkedViewChanged(self, view, axis): self.setYRange(y1, y2, padding=0) finally: view.blockLink(False) - + def screenGeometry(self): """return the screen geometry of the viewbox""" v = self.getViewWidget() @@ -1056,7 +1079,7 @@ def screenGeometry(self): def itemsChanged(self): ## called when items are added/removed from self.childGroup self.updateAutoRange() - + def itemBoundsChanged(self, item): self._itemBoundsCache.pop(item, None) if (self.state['autoRange'][0] is not False) or (self.state['autoRange'][1] is not False): @@ -1068,13 +1091,16 @@ def _invertAxis(self, ax, inv): key = 'xy'[ax] + 'Inverted' if self.state[key] == inv: return - + self.state[key] = inv self._matrixNeedsUpdate = True # updateViewRange won't detect this for us self.updateViewRange() self.update() self.sigStateChanged.emit(self) - self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][ax])) + if ax: + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][ax])) + else: + self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][ax])) def invertY(self, b=True): """ @@ -1084,7 +1110,7 @@ def invertY(self, b=True): def yInverted(self): return self.state['yInverted'] - + def invertX(self, b=True): """ By default, the positive x-axis points rightward on the screen. Use invertX(True) to reverse the x-axis. @@ -1093,127 +1119,120 @@ def invertX(self, b=True): def xInverted(self): return self.state['xInverted'] - + + def setBorder(self, *args, **kwds): + """ + Set the pen used to draw border around the view + + If border is None, then no border will be drawn. + + Added in version 0.9.10 + + See :func:`mkPen ` for arguments. + """ + self.border = fn.mkPen(*args, **kwds) + self.borderRect.setPen(self.border) + def setAspectLocked(self, lock=True, ratio=1): """ If the aspect ratio is locked, view scaling must always preserve the aspect ratio. By default, the ratio is set to 1; x and y both have the same scaling. This ratio can be overridden (xScale/yScale), or use None to lock in the current ratio. """ - + if not lock: if self.state['aspectLocked'] == False: return self.state['aspectLocked'] = False else: - rect = self.rect() - vr = self.viewRect() - if rect.height() == 0 or vr.width() == 0 or vr.height() == 0: - currentRatio = 1.0 - else: - currentRatio = (rect.width()/float(rect.height())) / (vr.width()/vr.height()) + currentRatio = self.getAspectRatio() if ratio is None: ratio = currentRatio if self.state['aspectLocked'] == ratio: # nothing to change return self.state['aspectLocked'] = ratio if ratio != currentRatio: ## If this would change the current range, do that now - #self.setRange(0, self.state['viewRange'][0][0], self.state['viewRange'][0][1]) self.updateViewRange() - + self.updateAutoRange() self.updateViewRange() self.sigStateChanged.emit(self) - + def childTransform(self): """ Return the transform that maps from child(item in the childGroup) coordinates to local coordinates. (This maps from inside the viewbox to outside) - """ - if self._matrixNeedsUpdate: - self.updateMatrix() + """ + self.updateMatrix() m = self.childGroup.transform() - #m1 = QtGui.QTransform() - #m1.translate(self.childGroup.pos().x(), self.childGroup.pos().y()) - return m #*m1 + return m def mapToView(self, obj): """Maps from the local coordinates of the ViewBox to the coordinate system displayed inside the ViewBox""" + self.updateMatrix() m = fn.invertQTransform(self.childTransform()) return m.map(obj) def mapFromView(self, obj): """Maps from the coordinate system displayed inside the ViewBox to the local coordinates of the ViewBox""" + self.updateMatrix() m = self.childTransform() return m.map(obj) def mapSceneToView(self, obj): """Maps from scene coordinates to the coordinate system displayed inside the ViewBox""" + self.updateMatrix() return self.mapToView(self.mapFromScene(obj)) def mapViewToScene(self, obj): """Maps from the coordinate system displayed inside the ViewBox to scene coordinates""" + self.updateMatrix() return self.mapToScene(self.mapFromView(obj)) - + def mapFromItemToView(self, item, obj): """Maps *obj* from the local coordinate system of *item* to the view coordinates""" + self.updateMatrix() return self.childGroup.mapFromItem(item, obj) #return self.mapSceneToView(item.mapToScene(obj)) def mapFromViewToItem(self, item, obj): """Maps *obj* from view coordinates to the local coordinate system of *item*.""" + self.updateMatrix() return self.childGroup.mapToItem(item, obj) - #return item.mapFromScene(self.mapViewToScene(obj)) def mapViewToDevice(self, obj): + self.updateMatrix() return self.mapToDevice(self.mapFromView(obj)) - + def mapDeviceToView(self, obj): + self.updateMatrix() return self.mapToView(self.mapFromDevice(obj)) - + def viewPixelSize(self): """Return the (width, height) of a screen pixel in view coordinates.""" o = self.mapToView(Point(0,0)) px, py = [Point(self.mapToView(v) - o) for v in self.pixelVectors()] return (px.length(), py.length()) - - + def itemBoundingRect(self, item): """Return the bounding rect of the item in view coordinates""" return self.mapSceneToView(item.sceneBoundingRect()).boundingRect() - - #def viewScale(self): - #vr = self.viewRect() - ##print "viewScale:", self.range - #xd = vr.width() - #yd = vr.height() - #if xd == 0 or yd == 0: - #print "Warning: 0 range in view:", xd, yd - #return np.array([1,1]) - - ##cs = self.canvas().size() - #cs = self.boundingRect() - #scale = np.array([cs.width() / xd, cs.height() / yd]) - ##print "view scale:", scale - #return scale def wheelEvent(self, ev, axis=None): - mask = np.array(self.state['mouseEnabled'], dtype=np.float) - if axis is not None and axis >= 0 and axis < len(mask): - mv = mask[axis] - mask[:] = 0 - mask[axis] = mv - s = ((mask * 0.02) + 1) ** (ev.delta() * self.state['wheelScaleFactor']) # actual scaling factor - + if axis in (0, 1): + mask = [False, False] + mask[axis] = self.state['mouseEnabled'][axis] + else: + mask = self.state['mouseEnabled'][:] + s = 1.02 ** (ev.delta() * self.state['wheelScaleFactor']) # actual scaling factor + s = [(None if m is False else s) for m in mask] center = Point(fn.invertQTransform(self.childGroup.transform()).map(ev.pos())) - #center = ev.pos() - + self._resetTarget() self.scaleBy(s, center) - self.sigRangeChangedManually.emit(self.state['mouseEnabled']) ev.accept() + self.sigRangeChangedManually.emit(mask) - def mouseClickEvent(self, ev): if ev.button() == QtCore.Qt.RightButton and self.menuEnabled(): ev.accept() @@ -1221,8 +1240,9 @@ def mouseClickEvent(self, ev): def raiseContextMenu(self, ev): menu = self.getMenu(ev) - self.scene().addParentContextMenus(self, menu, ev) - menu.popup(ev.screenPos().toPoint()) + if menu is not None: + self.scene().addParentContextMenus(self, menu, ev) + menu.popup(ev.screenPos().toPoint()) def getMenu(self, ev): return self.menu @@ -1233,7 +1253,7 @@ def getContextMenus(self, event): def mouseDragEvent(self, ev, axis=None): ## if axis is specified, event will only affect that axis. ev.accept() ## we accept all buttons - + pos = ev.pos() lastPos = ev.lastPos() dif = pos - lastPos @@ -1251,7 +1271,6 @@ def mouseDragEvent(self, ev, axis=None): if ev.isFinish(): ## This is the final move in the drag; change the view scale now #print "finish" self.rbScaleBox.hide() - #ax = QtCore.QRectF(Point(self.pressPos), Point(self.mousePos)) ax = QtCore.QRectF(Point(ev.buttonDownPos(ev.button())), Point(pos)) ax = self.childGroup.mapRectFromParent(ax) self.showAxRect(ax) @@ -1265,7 +1284,7 @@ def mouseDragEvent(self, ev, axis=None): tr = self.mapToView(tr) - self.mapToView(Point(0,0)) x = tr.x() if mask[0] == 1 else None y = tr.y() if mask[1] == 1 else None - + self._resetTarget() if x is not None or y is not None: self.translateBy(x=x, y=y) @@ -1274,18 +1293,18 @@ def mouseDragEvent(self, ev, axis=None): #print "vb.rightDrag" if self.state['aspectLocked'] is not False: mask[0] = 0 - + dif = ev.screenPos() - ev.lastScreenPos() dif = np.array([dif.x(), dif.y()]) dif[0] *= -1 s = ((mask * 0.02) + 1) ** dif - + tr = self.childGroup.transform() tr = fn.invertQTransform(tr) - + x = s[0] if mouseEnabled[0] == 1 else None y = s[1] if mouseEnabled[1] == 1 else None - + center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton))) self._resetTarget() self.scaleBy(x=x, y=y, center=center) @@ -1299,14 +1318,8 @@ def keyPressEvent(self, ev): ctrl-A : zooms out to the default "full" view of the plot ctrl-+ : moves forward in the zooming stack (if it exists) ctrl-- : moves backward in the zooming stack (if it exists) - + """ - #print ev.key() - #print 'I intercepted a key press, but did not accept it' - - ## not implemented yet ? - #self.keypress.sigkeyPressEvent.emit() - ev.accept() if ev.text() == '-': self.scaleHistory(-1) @@ -1324,7 +1337,6 @@ def scaleHistory(self, d): if ptr != self.axHistoryPointer: self.axHistoryPointer = ptr self.showAxRect(self.axHistory[ptr]) - def updateScaleBox(self, p1, p2): r = QtCore.QRectF(p1, p2) @@ -1334,30 +1346,23 @@ def updateScaleBox(self, p1, p2): self.rbScaleBox.scale(r.width(), r.height()) self.rbScaleBox.show() - def showAxRect(self, ax): - self.setRange(ax.normalized()) # be sure w, h are correct coordinates + def showAxRect(self, ax, **kwargs): + """Set the visible range to the given rectangle + Passes keyword arguments to setRange + """ + self.setRange(ax.normalized(), **kwargs) # be sure w, h are correct coordinates self.sigRangeChangedManually.emit(self.state['mouseEnabled']) - #def mouseRect(self): - #vs = self.viewScale() - #vr = self.state['viewRange'] - ## Convert positions from screen (view) pixel coordinates to axis coordinates - #ax = QtCore.QRectF(self.pressPos[0]/vs[0]+vr[0][0], -(self.pressPos[1]/vs[1]-vr[1][1]), - #(self.mousePos[0]-self.pressPos[0])/vs[0], -(self.mousePos[1]-self.pressPos[1])/vs[1]) - #return(ax) - def allChildren(self, item=None): """Return a list of all children and grandchildren of this ViewBox""" if item is None: item = self.childGroup - + children = [item] for ch in item.childItems(): children.extend(self.allChildren(ch)) return children - - - + def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): """Return the bounding range of all children. [[xmin, xmax], [ymin, ymax]] @@ -1366,22 +1371,20 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): profiler = debug.Profiler() if items is None: items = self.addedItems - + ## measure pixel dimensions in view box px, py = [v.length() if v is not None else 0 for v in self.childGroup.pixelVectors()] - + ## First collect all boundary information itemBounds = [] for item in items: - if not item.isVisible(): + if not item.isVisible() or not item.scene() is self.scene(): continue - + useX = True useY = True - + if hasattr(item, 'dataBounds'): - #bounds = self._itemBoundsCache.get(item, None) - #if bounds is None: if frac is None: frac = (1.0, 1.0) xr = item.dataBounds(0, frac=frac[0], orthoRange=orthoRange[0]) @@ -1396,27 +1399,24 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): bounds = QtCore.QRectF(xr[0], yr[0], xr[1]-xr[0], yr[1]-yr[0]) bounds = self.mapFromItemToView(item, bounds).boundingRect() - + if not any([useX, useY]): continue - + ## If we are ignoring only one axis, we need to check for rotations if useX != useY: ## != means xor ang = round(item.transformAngle()) if ang == 0 or ang == 180: pass elif ang == 90 or ang == 270: - useX, useY = useY, useX + useX, useY = useY, useX else: ## Item is rotated at non-orthogonal angle, ignore bounds entirely. ## Not really sure what is the expected behavior in this case. - continue ## need to check for item rotations and decide how best to apply this boundary. - - + continue ## need to check for item rotations and decide how best to apply this boundary. + + itemBounds.append((bounds, useX, useY, pxPad)) - #self._itemBoundsCache[item] = (bounds, useX, useY) - #else: - #bounds, useX, useY = bounds else: if int(item.flags() & item.ItemHasNoContents) > 0: continue @@ -1424,9 +1424,7 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): bounds = item.boundingRect() bounds = self.mapFromItemToView(item, bounds).boundingRect() itemBounds.append((bounds, True, True, 0)) - - #print itemBounds - + ## determine tentative new range range = [None, None] for bounds, useX, useY, px in itemBounds: @@ -1441,15 +1439,12 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): else: range[0] = [bounds.left(), bounds.right()] profiler() - - #print "range", range - + ## Now expand any bounds that have a pixel margin ## This must be done _after_ we have a good estimate of the new range ## to ensure that the pixel size is roughly accurate. w = self.width() h = self.height() - #print "w:", w, "h:", h if w > 0 and range[0] is not None: pxSize = (range[0][1] - range[0][0]) / w for bounds, useX, useY, px in itemBounds: @@ -1466,7 +1461,7 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): range[1][1] = max(range[1][1], bounds.bottom() + px*pxSize) return range - + def childrenBoundingRect(self, *args, **kwds): range = self.childrenBounds(*args, **kwds) tr = self.targetRange() @@ -1474,121 +1469,95 @@ def childrenBoundingRect(self, *args, **kwds): range[0] = tr[0] if range[1] is None: range[1] = tr[1] - + bounds = QtCore.QRectF(range[0][0], range[1][0], range[0][1]-range[0][0], range[1][1]-range[1][0]) return bounds - + def updateViewRange(self, forceX=False, forceY=False): - ## Update viewRange to match targetRange as closely as possible, given - ## aspect ratio constraints. The *force* arguments are used to indicate + ## Update viewRange to match targetRange as closely as possible, given + ## aspect ratio constraints. The *force* arguments are used to indicate ## which axis (if any) should be unchanged when applying constraints. viewRange = [self.state['targetRange'][0][:], self.state['targetRange'][1][:]] changed = [False, False] - + #-------- Make correction for aspect ratio constraint ---------- - + # aspect is (widget w/h) / (view range w/h) aspect = self.state['aspectLocked'] # size ratio / view ratio tr = self.targetRect() bounds = self.rect() + + limits = (self.state['limits']['xLimits'], self.state['limits']['yLimits']) + minRng = [self.state['limits']['xRange'][0], self.state['limits']['yRange'][0]] + maxRng = [self.state['limits']['xRange'][1], self.state['limits']['yRange'][1]] + + for axis in [0, 1]: + if limits[axis][0] is None and limits[axis][1] is None and minRng[axis] is None and maxRng[axis] is None: + continue + + # max range cannot be larger than bounds, if they are given + if limits[axis][0] is not None and limits[axis][1] is not None: + if maxRng[axis] is not None: + maxRng[axis] = min(maxRng[axis], limits[axis][1] - limits[axis][0]) + else: + maxRng[axis] = limits[axis][1] - limits[axis][0] + if aspect is not False and 0 not in [aspect, tr.height(), bounds.height(), bounds.width()]: - + ## This is the view range aspect ratio we have requested targetRatio = tr.width() / tr.height() if tr.height() != 0 else 1 ## This is the view range aspect ratio we need to obey aspect constraint viewRatio = (bounds.width() / bounds.height() if bounds.height() != 0 else 1) / aspect viewRatio = 1 if viewRatio == 0 else viewRatio - - # Decide which range to keep unchanged + + # Calculate both the x and y ranges that would be needed to obtain the desired aspect ratio + dy = 0.5 * (tr.width() / viewRatio - tr.height()) + dx = 0.5 * (tr.height() * viewRatio - tr.width()) + + rangeY = [self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy] + rangeX = [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx] + + canidateRange = [rangeX, rangeY] + + # Decide which range to try to keep unchanged #print self.name, "aspect:", aspect, "changed:", changed, "auto:", self.state['autoRange'] if forceX: ax = 0 elif forceY: ax = 1 else: - # if we are not required to keep a particular axis unchanged, - # then make the entire target range visible + # if we are not required to keep a particular axis unchanged, + # then try to make the entire target range visible ax = 0 if targetRatio > viewRatio else 1 - - if ax == 0: + target = 0 if ax == 1 else 1 + # See if this choice would cause out-of-range issues + if maxRng is not None or minRng is not None: + diff = canidateRange[target][1] - canidateRange[target][0] + if maxRng[target] is not None and diff > maxRng[target] or \ + minRng[target] is not None and diff < minRng[target]: + # tweak the target range down so we can still pan properly + self.state['targetRange'][ax] = canidateRange[ax] + ax = target # Switch the "fixed" axes + + if ax == 0: ## view range needs to be taller than target - dy = 0.5 * (tr.width() / viewRatio - tr.height()) if dy != 0: changed[1] = True - viewRange[1] = [self.state['targetRange'][1][0] - dy, self.state['targetRange'][1][1] + dy] + viewRange[1] = rangeY else: ## view range needs to be wider than target - dx = 0.5 * (tr.height() * viewRatio - tr.width()) if dx != 0: changed[0] = True - viewRange[0] = [self.state['targetRange'][0][0] - dx, self.state['targetRange'][0][1] + dx] + viewRange[0] = rangeX - - # ----------- Make corrections for view limits ----------- - - limits = (self.state['limits']['xLimits'], self.state['limits']['yLimits']) - minRng = [self.state['limits']['xRange'][0], self.state['limits']['yRange'][0]] - maxRng = [self.state['limits']['xRange'][1], self.state['limits']['yRange'][1]] - - for axis in [0, 1]: - if limits[axis][0] is None and limits[axis][1] is None and minRng[axis] is None and maxRng[axis] is None: - continue - - # max range cannot be larger than bounds, if they are given - if limits[axis][0] is not None and limits[axis][1] is not None: - if maxRng[axis] is not None: - maxRng[axis] = min(maxRng[axis], limits[axis][1]-limits[axis][0]) - else: - maxRng[axis] = limits[axis][1]-limits[axis][0] - - #print "\nLimits for axis %d: range=%s min=%s max=%s" % (axis, limits[axis], minRng[axis], maxRng[axis]) - #print "Starting range:", viewRange[axis] - - # Apply xRange, yRange - diff = viewRange[axis][1] - viewRange[axis][0] - if maxRng[axis] is not None and diff > maxRng[axis]: - delta = maxRng[axis] - diff - changed[axis] = True - elif minRng[axis] is not None and diff < minRng[axis]: - delta = minRng[axis] - diff - changed[axis] = True - else: - delta = 0 - - viewRange[axis][0] -= delta/2. - viewRange[axis][1] += delta/2. - - #print "after applying min/max:", viewRange[axis] - - # Apply xLimits, yLimits - mn, mx = limits[axis] - if mn is not None and viewRange[axis][0] < mn: - delta = mn - viewRange[axis][0] - viewRange[axis][0] += delta - viewRange[axis][1] += delta - changed[axis] = True - elif mx is not None and viewRange[axis][1] > mx: - delta = mx - viewRange[axis][1] - viewRange[axis][0] += delta - viewRange[axis][1] += delta - changed[axis] = True - - #print "after applying edge limits:", viewRange[axis] changed = [(viewRange[i][0] != self.state['viewRange'][i][0]) or (viewRange[i][1] != self.state['viewRange'][i][1]) for i in (0,1)] self.state['viewRange'] = viewRange - - # emit range change signals - if changed[0]: - self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) - if changed[1]: - self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) - + if any(changed): - self.sigRangeChanged.emit(self, self.state['viewRange']) - self.update() self._matrixNeedsUpdate = True - + self.update() + # Inform linked views that the range has changed for ax in [0, 1]: if not changed[ax]: @@ -1596,11 +1565,20 @@ def updateViewRange(self, forceX=False, forceY=False): link = self.linkedView(ax) if link is not None: link.linkedViewChanged(self, ax) - + + # emit range change signals + if changed[0]: + self.sigXRangeChanged.emit(self, tuple(self.state['viewRange'][0])) + if changed[1]: + self.sigYRangeChanged.emit(self, tuple(self.state['viewRange'][1])) + self.sigRangeChanged.emit(self, self.state['viewRange']) + def updateMatrix(self, changed=None): + if not self._matrixNeedsUpdate: + return ## Make the childGroup's transform match the requested viewRange. bounds = self.rect() - + vr = self.viewRect() if vr.height() == 0 or vr.width() == 0: return @@ -1610,30 +1588,28 @@ def updateMatrix(self, changed=None): if self.state['xInverted']: scale = scale * Point(-1, 1) m = QtGui.QTransform() - + ## First center the viewport at 0 center = bounds.center() m.translate(center.x(), center.y()) - + ## Now scale and translate properly m.scale(scale[0], scale[1]) st = Point(vr.center()) m.translate(-st[0], -st[1]) - + self.childGroup.setTransform(m) - - self.sigTransformChanged.emit(self) ## segfaults here: 1 self._matrixNeedsUpdate = False + self.sigTransformChanged.emit(self) ## segfaults here: 1 + def paint(self, p, opt, widget): - self.checkSceneChange() - if self.border is not None: bounds = self.shape() p.setPen(self.border) #p.fillRect(bounds, QtGui.QColor(0, 0, 0)) p.drawPath(bounds) - + #p.setPen(fn.mkPen('r')) #path = QtGui.QPainterPath() #path.addRect(self.targetRect()) @@ -1647,45 +1623,36 @@ def updateBackground(self): else: self.background.show() self.background.setBrush(fn.mkBrush(bg)) - - + def updateViewLists(self): try: self.window() except RuntimeError: ## this view has already been deleted; it will probably be collected shortly. return - - def cmpViews(a, b): - wins = 100 * cmp(a.window() is self.window(), b.window() is self.window()) - alpha = cmp(a.name, b.name) - return wins + alpha - + + def view_key(view): + return (view.window() is self.window(), view.name) + ## make a sorted list of all named views - nv = list(ViewBox.NamedViews.values()) - #print "new view list:", nv - sortList(nv, cmpViews) ## see pyqtgraph.python2_3.sortList - + nv = sorted(ViewBox.NamedViews.values(), key=view_key) + if self in nv: nv.remove(self) - - self.menu.setViewList(nv) - + + if self.menu is not None: + self.menu.setViewList(nv) + for ax in [0,1]: link = self.state['linkedViews'][ax] if isinstance(link, basestring): ## axis has not been linked yet; see if it's possible now for v in nv: if link == v.name: self.linkView(ax, v) - #print "New view list:", nv - #print "linked views:", self.state['linkedViews'] @staticmethod def updateAllViewLists(): - #print "Update:", ViewBox.AllViews.keys() - #print "Update:", ViewBox.NamedViews.keys() for v in ViewBox.AllViews: v.updateViewLists() - @staticmethod def forgetView(vid, name): @@ -1708,7 +1675,7 @@ def quit(): for k in ViewBox.AllViews: if isQObjectAlive(k) and getConfigOption('crashWarning'): sys.stderr.write('Warning: ViewBox should be closed before application exit.\n') - + try: k.destroyed.disconnect() except RuntimeError: ## signal is already disconnected. @@ -1717,7 +1684,7 @@ def quit(): pass except AttributeError: # PySide has deleted signal pass - + def locate(self, item, timeout=3.0, children=False): """ Temporarily display the bounding rect of an item and lines connecting to the center of the view. @@ -1725,16 +1692,16 @@ def locate(self, item, timeout=3.0, children=False): if allChildren is True, then the bounding rect of all item's children will be shown instead. """ self.clearLocate() - + if item.scene() is not self.scene(): raise Exception("Item does not share a scene with this ViewBox.") - + c = self.viewRect().center() if children: br = self.mapFromItemToView(item, item.childrenBoundingRect()).boundingRect() else: br = self.mapFromItemToView(item, item.boundingRect()).boundingRect() - + g = ItemGroup() g.setParentItem(self.childGroup) self.locateGroup = g @@ -1745,11 +1712,11 @@ def locate(self, item, timeout=3.0, children=False): line = QtGui.QGraphicsLineItem(c.x(), c.y(), p.x(), p.y()) line.setParentItem(g) g.lines.append(line) - + for item in g.childItems(): item.setPen(fn.mkPen(color='y', width=3)) g.setZValue(1000000) - + if children: g.path = QtGui.QGraphicsPathItem(g.childrenShape()) else: @@ -1757,13 +1724,14 @@ def locate(self, item, timeout=3.0, children=False): g.path.setParentItem(g) g.path.setPen(fn.mkPen('g')) g.path.setZValue(100) - + QtCore.QTimer.singleShot(timeout*1000, self.clearLocate) - + def clearLocate(self): if self.locateGroup is None: return self.scene().removeItem(self.locateGroup) self.locateGroup = None + from .ViewBoxMenu import ViewBoxMenu diff --git a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py index 10392d7e00..1f44bdd64d 100644 --- a/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py +++ b/pyqtgraph/graphicsItems/ViewBox/ViewBoxMenu.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from ...Qt import QtCore, QtGui, QT_LIB from ...python2_3 import asUnicode from ...WidgetGroup import WidgetGroup @@ -8,7 +9,9 @@ from .axisCtrlTemplate_pyside import Ui_Form as AxisCtrlTemplate elif QT_LIB == 'PyQt5': from .axisCtrlTemplate_pyqt5 import Ui_Form as AxisCtrlTemplate - +elif QT_LIB == 'PySide2': + from .axisCtrlTemplate_pyside2 import Ui_Form as AxisCtrlTemplate + import weakref class ViewBoxMenu(QtGui.QMenu): @@ -46,8 +49,8 @@ def __init__(self, view): connects = [ (ui.mouseCheck.toggled, 'MouseToggled'), (ui.manualRadio.clicked, 'ManualClicked'), - (ui.minText.editingFinished, 'MinTextChanged'), - (ui.maxText.editingFinished, 'MaxTextChanged'), + (ui.minText.editingFinished, 'RangeTextChanged'), + (ui.maxText.editingFinished, 'RangeTextChanged'), (ui.autoRadio.clicked, 'AutoClicked'), (ui.autoPercentSpin.valueChanged, 'AutoSpinChanged'), (ui.linkCombo.currentIndexChanged, 'LinkComboChanged'), @@ -160,14 +163,10 @@ def xMouseToggled(self, b): def xManualClicked(self): self.view().enableAutoRange(ViewBox.XAxis, False) - def xMinTextChanged(self): + def xRangeTextChanged(self): self.ctrl[0].manualRadio.setChecked(True) - self.view().setXRange(float(self.ctrl[0].minText.text()), float(self.ctrl[0].maxText.text()), padding=0) + self.view().setXRange(*self._validateRangeText(0), padding=0) - def xMaxTextChanged(self): - self.ctrl[0].manualRadio.setChecked(True) - self.view().setXRange(float(self.ctrl[0].minText.text()), float(self.ctrl[0].maxText.text()), padding=0) - def xAutoClicked(self): val = self.ctrl[0].autoPercentSpin.value() * 0.01 self.view().enableAutoRange(ViewBox.XAxis, val) @@ -192,13 +191,9 @@ def yMouseToggled(self, b): def yManualClicked(self): self.view().enableAutoRange(ViewBox.YAxis, False) - def yMinTextChanged(self): + def yRangeTextChanged(self): self.ctrl[1].manualRadio.setChecked(True) - self.view().setYRange(float(self.ctrl[1].minText.text()), float(self.ctrl[1].maxText.text()), padding=0) - - def yMaxTextChanged(self): - self.ctrl[1].manualRadio.setChecked(True) - self.view().setYRange(float(self.ctrl[1].minText.text()), float(self.ctrl[1].maxText.text()), padding=0) + self.view().setYRange(*self._validateRangeText(1), padding=0) def yAutoClicked(self): val = self.ctrl[1].autoPercentSpin.value() * 0.01 @@ -263,6 +258,20 @@ def setViewList(self, views): if changed: c.setCurrentIndex(0) c.currentIndexChanged.emit(c.currentIndex()) + + def _validateRangeText(self, axis): + """Validate range text inputs. Return current value(s) if invalid.""" + inputs = (self.ctrl[axis].minText.text(), + self.ctrl[axis].maxText.text()) + vals = self.view().viewRange()[axis] + for i, text in enumerate(inputs): + try: + vals[i] = float(text) + except ValueError: + # could not convert string to float + pass + return vals + from .ViewBox import ViewBox diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui index 297fce75a1..01bdf93e0a 100644 --- a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui @@ -17,7 +17,7 @@ - Form + PyQtGraph diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py index 5d952741c8..b54153fc15 100644 --- a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt.py @@ -78,7 +78,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) self.label.setText(_translate("Form", "Link Axis:", None)) self.linkCombo.setToolTip(_translate("Form", "

Links this axis with another view. When linked, both views will display the same data range.

", None)) self.autoPercentSpin.setToolTip(_translate("Form", "

Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.

", None)) diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt5.py b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt5.py index 78da6eea58..0a28e7f6a7 100644 --- a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt5.py +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyqt5.py @@ -65,7 +65,7 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.label.setText(_translate("Form", "Link Axis:")) self.linkCombo.setToolTip(_translate("Form", "

Links this axis with another view. When linked, both views will display the same data range.

")) self.autoPercentSpin.setToolTip(_translate("Form", "

Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.

")) diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py index 9ddeb5d111..c90206b590 100644 --- a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside.py @@ -64,7 +64,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.label.setText(QtGui.QApplication.translate("Form", "Link Axis:", None, QtGui.QApplication.UnicodeUTF8)) self.linkCombo.setToolTip(QtGui.QApplication.translate("Form", "

Links this axis with another view. When linked, both views will display the same data range.

", None, QtGui.QApplication.UnicodeUTF8)) self.autoPercentSpin.setToolTip(QtGui.QApplication.translate("Form", "

Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.

", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside2.py b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside2.py new file mode 100644 index 0000000000..401c52fc2e --- /dev/null +++ b/pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate_pyside2.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file './pyqtgraph/graphicsItems/ViewBox/axisCtrlTemplate.ui' +# +# Created: Wed Mar 26 15:09:28 2014 +# by: PyQt5 UI code generator 5.0.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(186, 154) + Form.setMaximumSize(QtCore.QSize(200, 16777215)) + self.gridLayout = QtWidgets.QGridLayout(Form) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setSpacing(0) + self.gridLayout.setObjectName("gridLayout") + self.label = QtWidgets.QLabel(Form) + self.label.setObjectName("label") + self.gridLayout.addWidget(self.label, 7, 0, 1, 2) + self.linkCombo = QtWidgets.QComboBox(Form) + self.linkCombo.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents) + self.linkCombo.setObjectName("linkCombo") + self.gridLayout.addWidget(self.linkCombo, 7, 2, 1, 2) + self.autoPercentSpin = QtWidgets.QSpinBox(Form) + self.autoPercentSpin.setEnabled(True) + self.autoPercentSpin.setMinimum(1) + self.autoPercentSpin.setMaximum(100) + self.autoPercentSpin.setSingleStep(1) + self.autoPercentSpin.setProperty("value", 100) + self.autoPercentSpin.setObjectName("autoPercentSpin") + self.gridLayout.addWidget(self.autoPercentSpin, 2, 2, 1, 2) + self.autoRadio = QtWidgets.QRadioButton(Form) + self.autoRadio.setChecked(True) + self.autoRadio.setObjectName("autoRadio") + self.gridLayout.addWidget(self.autoRadio, 2, 0, 1, 2) + self.manualRadio = QtWidgets.QRadioButton(Form) + self.manualRadio.setObjectName("manualRadio") + self.gridLayout.addWidget(self.manualRadio, 1, 0, 1, 2) + self.minText = QtWidgets.QLineEdit(Form) + self.minText.setObjectName("minText") + self.gridLayout.addWidget(self.minText, 1, 2, 1, 1) + self.maxText = QtWidgets.QLineEdit(Form) + self.maxText.setObjectName("maxText") + self.gridLayout.addWidget(self.maxText, 1, 3, 1, 1) + self.invertCheck = QtWidgets.QCheckBox(Form) + self.invertCheck.setObjectName("invertCheck") + self.gridLayout.addWidget(self.invertCheck, 5, 0, 1, 4) + self.mouseCheck = QtWidgets.QCheckBox(Form) + self.mouseCheck.setChecked(True) + self.mouseCheck.setObjectName("mouseCheck") + self.gridLayout.addWidget(self.mouseCheck, 6, 0, 1, 4) + self.visibleOnlyCheck = QtWidgets.QCheckBox(Form) + self.visibleOnlyCheck.setObjectName("visibleOnlyCheck") + self.gridLayout.addWidget(self.visibleOnlyCheck, 3, 2, 1, 2) + self.autoPanCheck = QtWidgets.QCheckBox(Form) + self.autoPanCheck.setObjectName("autoPanCheck") + self.gridLayout.addWidget(self.autoPanCheck, 4, 2, 1, 2) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.label.setText(_translate("Form", "Link Axis:")) + self.linkCombo.setToolTip(_translate("Form", "

Links this axis with another view. When linked, both views will display the same data range.

")) + self.autoPercentSpin.setToolTip(_translate("Form", "

Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.

")) + self.autoPercentSpin.setSuffix(_translate("Form", "%")) + self.autoRadio.setToolTip(_translate("Form", "

Automatically resize this axis whenever the displayed data is changed.

")) + self.autoRadio.setText(_translate("Form", "Auto")) + self.manualRadio.setToolTip(_translate("Form", "

Set the range for this axis manually. This disables automatic scaling.

")) + self.manualRadio.setText(_translate("Form", "Manual")) + self.minText.setToolTip(_translate("Form", "

Minimum value to display for this axis.

")) + self.minText.setText(_translate("Form", "0")) + self.maxText.setToolTip(_translate("Form", "

Maximum value to display for this axis.

")) + self.maxText.setText(_translate("Form", "0")) + self.invertCheck.setToolTip(_translate("Form", "

Inverts the display of this axis. (+y points downward instead of upward)

")) + self.invertCheck.setText(_translate("Form", "Invert Axis")) + self.mouseCheck.setToolTip(_translate("Form", "

Enables mouse interaction (panning, scaling) for this axis.

")) + self.mouseCheck.setText(_translate("Form", "Mouse Enabled")) + self.visibleOnlyCheck.setToolTip(_translate("Form", "

When checked, the axis will only auto-scale to data that is visible along the orthogonal axis.

")) + self.visibleOnlyCheck.setText(_translate("Form", "Visible Data Only")) + self.autoPanCheck.setToolTip(_translate("Form", "

When checked, the axis will automatically pan to center on the current data, but the scale along this axis will not change.

")) + self.autoPanCheck.setText(_translate("Form", "Auto Pan Only")) + diff --git a/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py b/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py index 68f4f497c1..9495bfc3f7 100644 --- a/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py +++ b/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBox.py @@ -31,7 +31,6 @@ def init_viewbox(): g = pg.GridItem() vb.addItem(g) - app.processEvents() def test_ViewBox(): @@ -64,7 +63,7 @@ def test_ViewBox(): assertMapping(vb, view1, size1) # test tall resize - win.resize(400, 800) + win.resize(200, 400) app.processEvents() w = vb.geometry().width() h = vb.geometry().height() @@ -72,6 +71,16 @@ def test_ViewBox(): size1 = QRectF(0, h, w, -h) assertMapping(vb, view1, size1) + win.close() + + +def test_ViewBox_setMenuEnabled(): + init_viewbox() + vb.setMenuEnabled(True) + assert vb.menu is not None + vb.setMenuEnabled(False) + assert vb.menu is None + skipreason = "Skipping this test until someone has time to fix it." @pytest.mark.skipif(True, reason=skipreason) diff --git a/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBoxZoom.py b/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBoxZoom.py new file mode 100644 index 0000000000..4bad9ee165 --- /dev/null +++ b/pyqtgraph/graphicsItems/ViewBox/tests/test_ViewBoxZoom.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +import pyqtgraph as pg +import pytest + +app = pg.mkQApp() + +def test_zoom_normal(): + vb = pg.ViewBox() + testRange = pg.QtCore.QRect(0, 0, 10, 20) + vb.setRange(testRange, padding=0) + vbViewRange = vb.getState()['viewRange'] + assert vbViewRange == [[testRange.left(), testRange.right()], + [testRange.top(), testRange.bottom()]] + +def test_zoom_limit(): + """Test zooming with X and Y limits set""" + vb = pg.ViewBox() + vb.setLimits(xMin=0, xMax=10, yMin=0, yMax=10) + + # Try zooming within limits. Should return unmodified + testRange = pg.QtCore.QRect(0, 0, 9, 9) + vb.setRange(testRange, padding=0) + vbViewRange = vb.getState()['viewRange'] + assert vbViewRange == [[testRange.left(), testRange.right()], + [testRange.top(), testRange.bottom()]] + + # And outside limits. both view range and targetRange should be set to limits + testRange = pg.QtCore.QRect(-5, -5, 16, 20) + vb.setRange(testRange, padding=0) + + expected = [[0, 10], [0, 10]] + vbState = vb.getState() + + assert vbState['targetRange'] == expected + assert vbState['viewRange'] == expected + +def test_zoom_range_limit(): + """Test zooming with XRange and YRange limits set, but no X and Y limits""" + vb = pg.ViewBox() + vb.setLimits(minXRange=5, maxXRange=10, minYRange=5, maxYRange=10) + + # Try something within limits + testRange = pg.QtCore.QRect(-15, -15, 7, 7) + vb.setRange(testRange, padding=0) + + expected = [[testRange.left(), testRange.right()], + [testRange.top(), testRange.bottom()]] + + vbViewRange = vb.getState()['viewRange'] + assert vbViewRange == expected + + # and outside limits + testRange = pg.QtCore.QRect(-15, -15, 17, 17) + + # Code should center the required width reduction, so move each side by 3 + expected = [[testRange.left() + 3, testRange.right() - 3], + [testRange.top() + 3, testRange.bottom() - 3]] + + vb.setRange(testRange, padding=0) + vbViewRange = vb.getState()['viewRange'] + vbTargetRange = vb.getState()['targetRange'] + + assert vbViewRange == expected + assert vbTargetRange == expected + +def test_zoom_ratio(): + """Test zooming with a fixed aspect ratio set""" + vb = pg.ViewBox(lockAspect=1) + + # Give the viewbox a size of the proper aspect ratio to keep things easy + vb.setFixedHeight(10) + vb.setFixedWidth(10) + + # request a range with a good ratio + testRange = pg.QtCore.QRect(0, 0, 10, 10) + vb.setRange(testRange, padding=0) + expected = [[testRange.left(), testRange.right()], + [testRange.top(), testRange.bottom()]] + + viewRange = vb.getState()['viewRange'] + viewWidth = viewRange[0][1] - viewRange[0][0] + viewHeight = viewRange[1][1] - viewRange[1][0] + + # Assert that the width and height are equal, since we locked the aspect ratio at 1 + assert viewWidth == viewHeight + + # and for good measure, that it is the same as the test range + assert viewRange == expected + + # Now try to set to something with a different aspect ratio + testRange = pg.QtCore.QRect(0, 0, 10, 20) + vb.setRange(testRange, padding=0) + + viewRange = vb.getState()['viewRange'] + viewWidth = viewRange[0][1] - viewRange[0][0] + viewHeight = viewRange[1][1] - viewRange[1][0] + + # Don't really care what we got here, as long as the width and height are the same + assert viewWidth == viewHeight + +def test_zoom_ratio2(): + """Slightly more complicated zoom ratio test, where the view box shape does not match the ratio""" + vb = pg.ViewBox(lockAspect=1) + + # twice as wide as tall + vb.setFixedHeight(10) + vb.setFixedWidth(20) + + # more or less random requested range + testRange = pg.QtCore.QRect(0, 0, 10, 15) + vb.setRange(testRange, padding=0) + + viewRange = vb.getState()['viewRange'] + viewWidth = viewRange[0][1] - viewRange[0][0] + viewHeight = viewRange[1][1] - viewRange[1][0] + + # View width should be twice as wide as the height, + # since the viewbox is twice as wide as it is tall. + assert viewWidth == 2 * viewHeight + +def test_zoom_ratio_with_limits1(): + """Test zoom with both ratio and limits set""" + vb = pg.ViewBox(lockAspect=1) + + # twice as wide as tall + vb.setFixedHeight(10) + vb.setFixedWidth(20) + + # set some limits + vb.setLimits(xMin=-5, xMax=5, yMin=-5, yMax=5) + + # Try to zoom too tall + testRange = pg.QtCore.QRect(0, 0, 6, 10) + vb.setRange(testRange, padding=0) + + viewRange = vb.getState()['viewRange'] + viewWidth = viewRange[0][1] - viewRange[0][0] + viewHeight = viewRange[1][1] - viewRange[1][0] + + # Make sure our view is within limits and the proper aspect ratio + assert viewRange[0][0] >= -5 + assert viewRange[0][1] <= 5 + assert viewRange[1][0] >= -5 + assert viewRange[1][1] <= 5 + assert viewWidth == 2 * viewHeight + +def test_zoom_ratio_with_limits2(): + vb = pg.ViewBox(lockAspect=1) + + # twice as wide as tall + vb.setFixedHeight(10) + vb.setFixedWidth(20) + + # set some limits + vb.setLimits(xMin=-5, xMax=5, yMin=-5, yMax=5) + + # Same thing, but out-of-range the other way + testRange = pg.QtCore.QRect(0, 0, 16, 6) + vb.setRange(testRange, padding=0) + + viewRange = vb.getState()['viewRange'] + viewWidth = viewRange[0][1] - viewRange[0][0] + viewHeight = viewRange[1][1] - viewRange[1][0] + + # Make sure our view is within limits and the proper aspect ratio + assert viewRange[0][0] >= -5 + assert viewRange[0][1] <= 5 + assert viewRange[1][0] >= -5 + assert viewRange[1][1] <= 5 + assert viewWidth == 2 * viewHeight + +def test_zoom_ratio_with_limits_out_of_range(): + vb = pg.ViewBox(lockAspect=1) + + # twice as wide as tall + vb.setFixedHeight(10) + vb.setFixedWidth(20) + + # set some limits + vb.setLimits(xMin=-5, xMax=5, yMin=-5, yMax=5) + + # Request something completely out-of-range and out-of-aspect + testRange = pg.QtCore.QRect(10, 10, 25, 100) + vb.setRange(testRange, padding=0) + + viewRange = vb.getState()['viewRange'] + viewWidth = viewRange[0][1] - viewRange[0][0] + viewHeight = viewRange[1][1] - viewRange[1][0] + + # Make sure our view is within limits and the proper aspect ratio + assert viewRange[0][0] >= -5 + assert viewRange[0][1] <= 5 + assert viewRange[1][0] >= -5 + assert viewRange[1][1] <= 5 + assert viewWidth == 2 * viewHeight + + +if __name__ == "__main__": + setup_module(None) + test_zoom_ratio() diff --git a/pyqtgraph/graphicsItems/tests/test_AxisItem.py b/pyqtgraph/graphicsItems/tests/test_AxisItem.py new file mode 100644 index 0000000000..8d89259ae8 --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_AxisItem.py @@ -0,0 +1,89 @@ +import pyqtgraph as pg + +app = pg.mkQApp() + +def test_AxisItem_stopAxisAtTick(monkeypatch): + def test_bottom(p, axisSpec, tickSpecs, textSpecs): + assert view.mapToView(axisSpec[1]).x() == 0.25 + assert view.mapToView(axisSpec[2]).x() == 0.75 + + def test_left(p, axisSpec, tickSpecs, textSpecs): + assert view.mapToView(axisSpec[1]).y() == 0.875 + assert view.mapToView(axisSpec[2]).y() == 0.125 + + plot = pg.PlotWidget() + view = plot.plotItem.getViewBox() + bottom = plot.getAxis("bottom") + bottom.setRange(0, 1) + bticks = [(0.25, "a"), (0.6, "b"), (0.75, "c")] + bottom.setTicks([bticks, bticks]) + bottom.setStyle(stopAxisAtTick=(True, True)) + monkeypatch.setattr(bottom, "drawPicture", test_bottom) + + left = plot.getAxis("left") + lticks = [(0.125, "a"), (0.55, "b"), (0.875, "c")] + left.setTicks([lticks, lticks]) + left.setRange(0, 1) + left.setStyle(stopAxisAtTick=(True, True)) + monkeypatch.setattr(left, "drawPicture", test_left) + + plot.show() + app.processEvents() + plot.close() + + +def test_AxisItem_viewUnlink(): + plot = pg.PlotWidget() + view = plot.plotItem.getViewBox() + axis = plot.getAxis("bottom") + assert axis.linkedView() == view + axis.unlinkFromView() + assert axis.linkedView() is None + + +class FakeSignal: + + def __init__(self): + self.calls = [] + + def connect(self, *args, **kwargs): + self.calls.append('connect') + + def disconnect(self, *args, **kwargs): + self.calls.append('disconnect') + + +class FakeView: + + def __init__(self): + self.sigYRangeChanged = FakeSignal() + self.sigXRangeChanged = FakeSignal() + self.sigResized = FakeSignal() + + +def test_AxisItem_bottomRelink(): + axis = pg.AxisItem('bottom') + fake_view = FakeView() + axis.linkToView(fake_view) + assert axis.linkedView() == fake_view + assert fake_view.sigYRangeChanged.calls == [] + assert fake_view.sigXRangeChanged.calls == ['connect'] + assert fake_view.sigResized.calls == ['connect'] + axis.unlinkFromView() + assert fake_view.sigYRangeChanged.calls == [] + assert fake_view.sigXRangeChanged.calls == ['connect', 'disconnect'] + assert fake_view.sigResized.calls == ['connect', 'disconnect'] + + +def test_AxisItem_leftRelink(): + axis = pg.AxisItem('left') + fake_view = FakeView() + axis.linkToView(fake_view) + assert axis.linkedView() == fake_view + assert fake_view.sigYRangeChanged.calls == ['connect'] + assert fake_view.sigXRangeChanged.calls == [] + assert fake_view.sigResized.calls == ['connect'] + axis.unlinkFromView() + assert fake_view.sigYRangeChanged.calls == ['connect', 'disconnect'] + assert fake_view.sigXRangeChanged.calls == [] + assert fake_view.sigResized.calls == ['connect', 'disconnect'] diff --git a/pyqtgraph/graphicsItems/tests/test_ErrorBarItem.py b/pyqtgraph/graphicsItems/tests/test_ErrorBarItem.py new file mode 100644 index 0000000000..2b922c1e08 --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_ErrorBarItem.py @@ -0,0 +1,39 @@ +import pyqtgraph as pg +import numpy as np + +app = pg.mkQApp() + + +def test_ErrorBarItem_defer_data(): + plot = pg.PlotWidget() + plot.show() + + # plot some data away from the origin to set the view rect + x = np.arange(5) + 10 + curve = pg.PlotCurveItem(x=x, y=x) + plot.addItem(curve) + app.processEvents() + r_no_ebi = plot.viewRect() + + # ErrorBarItem with no data shouldn't affect the view rect + err = pg.ErrorBarItem() + plot.addItem(err) + app.processEvents() + r_empty_ebi = plot.viewRect() + + assert r_no_ebi == r_empty_ebi + + err.setData(x=x, y=x, bottom=x, top=x) + app.processEvents() + r_ebi = plot.viewRect() + + assert r_empty_ebi != r_ebi + + # unset data, ErrorBarItem disappears and view rect goes back to original + err.setData(x=None, y=None) + app.processEvents() + r_clear_ebi = plot.viewRect() + + assert r_clear_ebi == r_no_ebi + + plot.close() diff --git a/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py b/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py index 112dd4d598..47dfd907eb 100644 --- a/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py +++ b/pyqtgraph/graphicsItems/tests/test_GraphicsItem.py @@ -34,14 +34,3 @@ def test_getViewWidget_deleted(): assert not pg.Qt.isQObjectAlive(view) assert item.getViewWidget() is None - - -#if __name__ == '__main__': - #view = pg.PlotItem() - #vref = weakref.ref(view) - #item = pg.InfiniteLine() - #view.addItem(item) - #del view - #gc.collect() - - \ No newline at end of file diff --git a/pyqtgraph/graphicsItems/tests/test_ImageItem.py b/pyqtgraph/graphicsItems/tests/test_ImageItem.py index 4f310bc372..91926fe40e 100644 --- a/pyqtgraph/graphicsItems/tests/test_ImageItem.py +++ b/pyqtgraph/graphicsItems/tests/test_ImageItem.py @@ -121,7 +121,7 @@ def test_ImageItem(transpose=False): assertImageApproved(w, 'imageitem/resolution_with_downsampling_y', 'Resolution test with downsampling across y axis.') assert img._lastDownsample == (1, 4) - view.hide() + w.hide() def test_ImageItem_axisorder(): # All image tests pass again using the opposite axis order @@ -134,7 +134,7 @@ def test_ImageItem_axisorder(): pg.setConfigOptions(imageAxisOrder=origMode) -@pytest.mark.skipif(pg.Qt.USE_PYSIDE, reason="pyside does not have qWait") +@pytest.mark.skipif(pg.Qt.QT_LIB=='PySide', reason="pyside does not have qWait") def test_dividebyzero(): import pyqtgraph as pg im = pg.image(pg.np.random.normal(size=(100,100))) diff --git a/pyqtgraph/graphicsItems/tests/test_PlotCurveItem.py b/pyqtgraph/graphicsItems/tests/test_PlotCurveItem.py index a3c34b1170..6d60d3e1ec 100644 --- a/pyqtgraph/graphicsItems/tests/test_PlotCurveItem.py +++ b/pyqtgraph/graphicsItems/tests/test_PlotCurveItem.py @@ -27,7 +27,8 @@ def test_PlotCurveItem(): c.setData(data, connect=np.array([1,1,1,0,1,1,0,0,1,0,0,0,1,1,0,0])) assertImageApproved(p, 'plotcurveitem/connectarray', "Plot curve with connection array.") - + + p.close() if __name__ == '__main__': diff --git a/pyqtgraph/graphicsItems/tests/test_PlotDataItem.py b/pyqtgraph/graphicsItems/tests/test_PlotDataItem.py new file mode 100644 index 0000000000..894afc742a --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_PlotDataItem.py @@ -0,0 +1,90 @@ +import numpy as np +import pyqtgraph as pg + +pg.mkQApp() + + +def test_fft(): + f = 20. + x = np.linspace(0, 1, 1000) + y = np.sin(2 * np.pi * f * x) + pd = pg.PlotDataItem(x, y) + pd.setFftMode(True) + x, y = pd.getData() + assert abs(x[np.argmax(y)] - f) < 0.03 + + x = np.linspace(0, 1, 1001) + y = np.sin(2 * np.pi * f * x) + pd.setData(x, y) + x, y = pd.getData() + assert abs(x[np.argmax(y)]- f) < 0.03 + + pd.setLogMode(True, False) + x, y = pd.getData() + assert abs(x[np.argmax(y)] - np.log10(f)) < 0.01 + +def test_setData(): + pdi = pg.PlotDataItem() + + #test empty data + pdi.setData([]) + + #test y data + y = list(np.random.normal(size=100)) + pdi.setData(y) + assert len(pdi.xData) == 100 + assert len(pdi.yData) == 100 + + #test x, y data + y += list(np.random.normal(size=50)) + x = np.linspace(5, 10, 150) + + pdi.setData(x, y) + assert len(pdi.xData) == 150 + assert len(pdi.yData) == 150 + + #test dict of x, y list + y += list(np.random.normal(size=50)) + x = list(np.linspace(5, 10, 200)) + pdi.setData({'x': x, 'y': y}) + assert len(pdi.xData) == 200 + assert len(pdi.yData) == 200 + +def test_clear(): + y = list(np.random.normal(size=100)) + x = np.linspace(5, 10, 100) + pdi = pg.PlotDataItem(x, y) + pdi.clear() + + assert pdi.xData == None + assert pdi.yData == None + +def test_clear_in_step_mode(): + w = pg.PlotWidget() + c = pg.PlotDataItem([1,4,2,3], [5,7,6], stepMode=True) + w.addItem(c) + c.clear() + +def test_clipping(): + y = np.random.normal(size=150) + x = np.exp2(np.linspace(5, 10, 150)) # non-uniform spacing + + w = pg.PlotWidget(autoRange=True, downsample=5) + c = pg.PlotDataItem(x, y) + w.addItem(c) + w.show() + + c.setClipToView(True) + + w.setXRange(200, 600) + + for x_min in range(100, 2**10 - 100, 100): + w.setXRange(x_min, x_min + 100) + + xDisp, _ = c.getData() + vr = c.viewRect() + + assert xDisp[0] <= vr.left() + assert xDisp[-1] >= vr.right() + + w.close() diff --git a/pyqtgraph/graphicsItems/tests/test_ROI.py b/pyqtgraph/graphicsItems/tests/test_ROI.py index 9e67fb8dd4..10c6009b69 100644 --- a/pyqtgraph/graphicsItems/tests/test_ROI.py +++ b/pyqtgraph/graphicsItems/tests/test_ROI.py @@ -1,13 +1,14 @@ +# -*- coding: utf-8 -*- +import sys import numpy as np import pytest import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtTest -from pyqtgraph.tests import assertImageApproved, mouseMove, mouseDrag, mouseClick, TransposedImageItem - +from pyqtgraph.tests import assertImageApproved, mouseMove, mouseDrag, mouseClick, TransposedImageItem, resizeWindow +import pytest app = pg.mkQApp() - def test_getArrayRegion(transpose=False): pr = pg.PolyLineROI([[0, 0], [27, 0], [0, 28]], closed=True) pr.setPos(1, 1) @@ -32,7 +33,6 @@ def test_getArrayRegion(transpose=False): finally: pg.setConfigOptions(imageAxisOrder=origMode) - def test_getArrayRegion_axisorder(): test_getArrayRegion(transpose=True) @@ -43,8 +43,7 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): #win = pg.GraphicsLayoutWidget() win = pg.GraphicsView() win.show() - win.resize(200, 400) - + resizeWindow(win, 200, 400) # Don't use Qt's layouts for testing--these generate unpredictable results. #vb1 = win.addViewBox() #win.nextRow() @@ -97,7 +96,6 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): vb2.enableAutoRange(True, True) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion', 'Simple ROI region selection.') with pytest.raises(TypeError): @@ -135,7 +133,12 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): rgn = roi.getArrayRegion(data, img1, axes=(1, 2)) img2.setImage(rgn[0, ..., 0]) app.processEvents() - assertImageApproved(win, name+'/roi_getarrayregion_inverty', 'Simple ROI region selection, view inverted.') + # on windows, one edge of one ROI handle is shifted slightly; letting this slide with pxCount=10 + if pg.Qt.QT_LIB in {'PyQt4', 'PySide'}: + pxCount = 10 + else: + pxCount=-1 + assertImageApproved(win, name+'/roi_getarrayregion_inverty', 'Simple ROI region selection, view inverted.', pxCount=pxCount) roi.setState(initState) img1.resetTransform() @@ -149,6 +152,7 @@ def check_getArrayRegion(roi, name, testResize=True, transpose=False): # allow the roi to be re-used roi.scene().removeItem(roi) + win.hide() def test_PolyLineROI(): rois = [ @@ -159,7 +163,7 @@ def test_PolyLineROI(): #plt = pg.plot() plt = pg.GraphicsView() plt.show() - plt.resize(200, 200) + resizeWindow(plt, 200, 200) vb = pg.ViewBox() plt.scene().addItem(vb) vb.resize(200, 200) @@ -210,17 +214,26 @@ def test_PolyLineROI(): # click segment mouseClick(plt, pt, QtCore.Qt.LeftButton) assertImageApproved(plt, 'roi/polylineroi/'+name+'_click_segment', 'Click mouse over segment.') + + # drag new handle + mouseMove(plt, pt+pg.Point(10, -10)) # pg bug: have to move the mouse off/on again to register hover + mouseDrag(plt, pt, pt + pg.Point(10, -10), QtCore.Qt.LeftButton) + assertImageApproved(plt, 'roi/polylineroi/'+name+'_drag_new_handle', 'Drag mouse over created handle.') + # clear all points r.clearPoints() assertImageApproved(plt, 'roi/polylineroi/'+name+'_clear', 'All points cleared.') assert len(r.getState()['points']) == 0 + # call setPoints r.setPoints(initState['points']) assertImageApproved(plt, 'roi/polylineroi/'+name+'_setpoints', 'Reset points to initial state.') assert len(r.getState()['points']) == 3 + # call setState r.setState(initState) assertImageApproved(plt, 'roi/polylineroi/'+name+'_setstate', 'Reset ROI to initial state.') assert len(r.getState()['points']) == 3 - - \ No newline at end of file + + plt.hide() + diff --git a/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py b/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py index acf6ad7214..ba1fb9d7e8 100644 --- a/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py +++ b/pyqtgraph/graphicsItems/tests/test_ScatterPlotItem.py @@ -1,3 +1,4 @@ +from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph as pg import numpy as np app = pg.mkQApp() @@ -7,9 +8,16 @@ def test_scatterplotitem(): plot = pg.PlotWidget() - # set view range equal to its bounding rect. + # set view range equal to its bounding rect. # This causes plots to look the same regardless of pxMode. plot.setRange(rect=plot.boundingRect()) + + # test SymbolAtlas accepts custom symbol + s = pg.ScatterPlotItem() + symbol = QtGui.QPainterPath() + symbol.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) + s.addPoints([{'pos': [0,0], 'data': 1, 'symbol': symbol}]) + for i, pxMode in enumerate([True, False]): for j, useCache in enumerate([True, False]): s = pg.ScatterPlotItem() @@ -17,14 +25,14 @@ def test_scatterplotitem(): plot.addItem(s) s.setData(x=np.array([10,40,20,30])+i*100, y=np.array([40,60,10,30])+j*100, pxMode=pxMode) s.addPoints(x=np.array([60, 70])+i*100, y=np.array([60, 70])+j*100, size=[20, 30]) - + # Test uniform spot updates s.setSize(10) s.setBrush('r') s.setPen('g') s.setSymbol('+') app.processEvents() - + # Test list spot updates s.setSize([10] * 6) s.setBrush([pg.mkBrush('r')] * 6) @@ -55,7 +63,7 @@ def test_scatterplotitem(): def test_init_spots(): plot = pg.PlotWidget() - # set view range equal to its bounding rect. + # set view range equal to its bounding rect. # This causes plots to look the same regardless of pxMode. plot.setRange(rect=plot.boundingRect()) spots = [ @@ -63,28 +71,28 @@ def test_init_spots(): {'pos': (1, 2), 'pen': None, 'brush': None, 'data': 'zzz'}, ] s = pg.ScatterPlotItem(spots=spots) - + # Check we can display without errors plot.addItem(s) app.processEvents() plot.clear() - + # check data is correct spots = s.points() - + defPen = pg.mkPen(pg.getConfigOption('foreground')) assert spots[0].pos().x() == 0 assert spots[0].pos().y() == 1 assert spots[0].pen() == defPen assert spots[0].data() is None - + assert spots[1].pos().x() == 1 assert spots[1].pos().y() == 2 assert spots[1].pen() == pg.mkPen(None) assert spots[1].brush() == pg.mkBrush(None) assert spots[1].data() == 'zzz' - + if __name__ == '__main__': test_scatterplotitem() diff --git a/pyqtgraph/graphicsItems/tests/test_TextItem.py b/pyqtgraph/graphicsItems/tests/test_TextItem.py new file mode 100644 index 0000000000..6667dfc511 --- /dev/null +++ b/pyqtgraph/graphicsItems/tests/test_TextItem.py @@ -0,0 +1,23 @@ +import pytest +import pyqtgraph as pg + +app = pg.mkQApp() + + +def test_TextItem_setAngle(): + plt = pg.plot() + plt.setXRange(-10, 10) + plt.setYRange(-20, 20) + item = pg.TextItem(text="test") + plt.addItem(item) + + t1 = item.transform() + + item.setAngle(30) + app.processEvents() + + t2 = item.transform() + + assert t1 != t2 + assert not t1.isRotating() + assert t2.isRotating() diff --git a/pyqtgraph/graphicsWindows.py b/pyqtgraph/graphicsWindows.py index 3a5953cf6a..aa62f4f1f6 100644 --- a/pyqtgraph/graphicsWindows.py +++ b/pyqtgraph/graphicsWindows.py @@ -1,28 +1,24 @@ # -*- coding: utf-8 -*- """ -graphicsWindows.py - Convenience classes which create a new window with PlotWidget or ImageView. -Copyright 2010 Luke Campagnola -Distributed under MIT/X11 license. See license.txt for more information. +DEPRECATED: The classes below are convenience classes that create a new window +containting a single, specific widget. These classes are now unnecessary because +it is possible to place any widget into its own window by simply calling its +show() method. """ -from .Qt import QtCore, QtGui +from .Qt import QtCore, QtGui, mkQApp from .widgets.PlotWidget import * from .imageview import * from .widgets.GraphicsLayoutWidget import GraphicsLayoutWidget from .widgets.GraphicsView import GraphicsView -QAPP = None - -def mkQApp(): - if QtGui.QApplication.instance() is None: - global QAPP - QAPP = QtGui.QApplication([]) class GraphicsWindow(GraphicsLayoutWidget): """ - Convenience subclass of :class:`GraphicsLayoutWidget - `. This class is intended for use from - the interactive python prompt. + (deprecated; use :class:`~pyqtgraph.GraphicsLayoutWidget` instead) + + Convenience subclass of :class:`~pyqtgraph.GraphicsLayoutWidget`. This class + is intended for use from the interactive python prompt. """ def __init__(self, title=None, size=(800,600), **kargs): mkQApp() @@ -34,6 +30,9 @@ def __init__(self, title=None, size=(800,600), **kargs): class TabWindow(QtGui.QMainWindow): + """ + (deprecated) + """ def __init__(self, title=None, size=(800,600)): mkQApp() QtGui.QMainWindow.__init__(self) @@ -45,39 +44,43 @@ def __init__(self, title=None, size=(800,600)): self.show() def __getattr__(self, attr): - if hasattr(self.cw, attr): - return getattr(self.cw, attr) - else: - raise NameError(attr) + return getattr(self.cw, attr) class PlotWindow(PlotWidget): + sigClosed = QtCore.Signal(object) + + """ + (deprecated; use :class:`~pyqtgraph.PlotWidget` instead) + """ def __init__(self, title=None, **kargs): mkQApp() - self.win = QtGui.QMainWindow() PlotWidget.__init__(self, **kargs) - self.win.setCentralWidget(self) - for m in ['resize']: - setattr(self, m, getattr(self.win, m)) if title is not None: - self.win.setWindowTitle(title) - self.win.show() + self.setWindowTitle(title) + self.show() + + def closeEvent(self, event): + PlotWidget.closeEvent(self, event) + self.sigClosed.emit(self) class ImageWindow(ImageView): + sigClosed = QtCore.Signal(object) + + """ + (deprecated; use :class:`~pyqtgraph.ImageView` instead) + """ def __init__(self, *args, **kargs): mkQApp() - self.win = QtGui.QMainWindow() - self.win.resize(800,600) + ImageView.__init__(self) if 'title' in kargs: - self.win.setWindowTitle(kargs['title']) + self.setWindowTitle(kargs['title']) del kargs['title'] - ImageView.__init__(self, self.win) if len(args) > 0 or len(kargs) > 0: self.setImage(*args, **kargs) - self.win.setCentralWidget(self) - for m in ['resize']: - setattr(self, m, getattr(self.win, m)) - #for m in ['setImage', 'autoRange', 'addItem', 'removeItem', 'blackLevel', 'whiteLevel', 'imageItem']: - #setattr(self, m, getattr(self.cw, m)) - self.win.show() + self.show() + + def closeEvent(self, event): + ImageView.closeEvent(self, event) + self.sigClosed.emit(self) diff --git a/pyqtgraph/imageview/ImageView.py b/pyqtgraph/imageview/ImageView.py index 36ae5c73e6..546d416460 100644 --- a/pyqtgraph/imageview/ImageView.py +++ b/pyqtgraph/imageview/ImageView.py @@ -12,12 +12,16 @@ - ROI plotting - Image normalization through a variety of methods """ -import os +import os, sys import numpy as np -from ..Qt import QtCore, QtGui, USE_PYSIDE -if USE_PYSIDE: +from ..Qt import QtCore, QtGui, QT_LIB +if QT_LIB == 'PySide': from .ImageViewTemplate_pyside import * +elif QT_LIB == 'PySide2': + from .ImageViewTemplate_pyside2 import * +elif QT_LIB == 'PyQt5': + from .ImageViewTemplate_pyqt5 import * else: from .ImageViewTemplate_pyqt import * @@ -26,6 +30,7 @@ from ..graphicsItems.LinearRegionItem import * from ..graphicsItems.InfiniteLine import * from ..graphicsItems.ViewBox import * +from ..graphicsItems.VTickGroup import VTickGroup from ..graphicsItems.GradientEditorItem import addGradientListToDocstring from .. import ptime as ptime from .. import debug as debug @@ -79,7 +84,8 @@ class ImageView(QtGui.QWidget): sigTimeChanged = QtCore.Signal(object, object) sigProcessingChanged = QtCore.Signal(object) - def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *args): + def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, + levelMode='mono', *args): """ By default, this class creates an :class:`ImageItem ` to display image data and a :class:`ViewBox ` to contain the ImageItem. @@ -101,6 +107,9 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *ar imageItem (ImageItem) If specified, this object will be used to display the image. Must be an instance of ImageItem or other compatible object. + levelMode See the *levelMode* argument to + :func:`HistogramLUTItem.__init__() + ` ============= ========================================================= Note: to display axis ticks inside the ImageView, instantiate it @@ -109,8 +118,10 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *ar pg.ImageView(view=pg.PlotItem()) """ QtGui.QWidget.__init__(self, parent, *args) - self.levelMax = 4096 - self.levelMin = 0 + self._imageLevels = None # [(min, max), ...] per channel image metrics + self.levelMin = None # min / max levels across all channels + self.levelMax = None + self.name = name self.image = None self.axes = {} @@ -118,8 +129,9 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *ar self.ui = Ui_Form() self.ui.setupUi(self) self.scene = self.ui.graphicsView.scene() + self.ui.histogram.setLevelMode(levelMode) - self.ignoreTimeLine = False + self.ignorePlaying = False if view is None: self.view = ViewBox() @@ -151,13 +163,15 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *ar self.normRoi.setZValue(20) self.view.addItem(self.normRoi) self.normRoi.hide() - self.roiCurve = self.ui.roiPlot.plot() - self.timeLine = InfiniteLine(0, movable=True) + self.roiCurves = [] + self.timeLine = InfiniteLine(0, movable=True, markers=[('^', 0), ('v', 1)]) self.timeLine.setPen((255, 255, 0, 200)) self.timeLine.setZValue(1) self.ui.roiPlot.addItem(self.timeLine) self.ui.splitter.setSizes([self.height()-35, 35]) self.ui.roiPlot.hideAxis('left') + self.frameTicks = VTickGroup(yrange=[0.8, 1], pen=0.4) + self.ui.roiPlot.addItem(self.frameTicks, ignoreBounds=True) self.keysPressed = {} self.playTimer = QtCore.QTimer() @@ -200,7 +214,7 @@ def __init__(self, parent=None, name="ImageView", view=None, imageItem=None, *ar self.roiClicked() ## initialize roi plot to correct shape / visibility - def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, xvals=None, pos=None, scale=None, transform=None, autoHistogramRange=True): + def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, xvals=None, pos=None, scale=None, transform=None, autoHistogramRange=True, levelMode=None): """ Set the image to be displayed in the widget. @@ -208,8 +222,9 @@ def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, **Arguments:** img (numpy array) the image to be displayed. See :func:`ImageItem.setImage` and *notes* below. - xvals (numpy array) 1D array of z-axis values corresponding to the third axis - in a 3D image. For video, this array should contain the time of each frame. + xvals (numpy array) 1D array of z-axis values corresponding to the first axis + in a 3D image. For video, this array should contain the time of each + frame. autoRange (bool) whether to scale/pan the view to fit the image. autoLevels (bool) whether to update the white/black levels to fit the image. levels (min, max); the white and black level values to use. @@ -224,6 +239,10 @@ def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, and *scale*. autoHistogramRange If True, the histogram y-range is automatically scaled to fit the image data. + levelMode If specified, this sets the user interaction mode for setting image + levels. Options are 'mono', which provides a single level control for + all image channels, and 'rgb' or 'rgba', which provide individual + controls for each channel. ================== =========================================================================== **Notes:** @@ -252,6 +271,8 @@ def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, self.image = img self.imageDisp = None + if levelMode is not None: + self.ui.histogram.setLevelMode(levelMode) profiler() @@ -310,10 +331,9 @@ def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, profiler() if self.axes['t'] is not None: - #self.ui.roiPlot.show() self.ui.roiPlot.setXRange(self.tVals.min(), self.tVals.max()) + self.frameTicks.setXVals(self.tVals) self.timeLine.setValue(0) - #self.ui.roiPlot.setMouseEnabled(False, False) if len(self.tVals) > 1: start = self.tVals.min() stop = self.tVals.max() + abs(self.tVals[-1] - self.tVals[0]) * 0.02 @@ -325,8 +345,7 @@ def setImage(self, img, autoRange=True, autoLevels=True, levels=None, axes=None, stop = 1 for s in [self.timeLine, self.normRgn]: s.setBounds([start, stop]) - #else: - #self.ui.roiPlot.hide() + profiler() self.imageItem.resetTransform() @@ -364,11 +383,14 @@ def play(self, rate): def autoLevels(self): """Set the min/max intensity levels automatically to match the image data.""" - self.setLevels(self.levelMin, self.levelMax) + self.setLevels(rgba=self._imageLevels) - def setLevels(self, min, max): - """Set the min/max (bright and dark) levels.""" - self.ui.histogram.setLevels(min, max) + def setLevels(self, *args, **kwds): + """Set the min/max (bright and dark) levels. + + See :func:`HistogramLUTItem.setLevels `. + """ + self.ui.histogram.setLevels(*args, **kwds) def autoRange(self): """Auto scale and pan the view around the image such that the image fills the view.""" @@ -377,22 +399,21 @@ def autoRange(self): def getProcessedImage(self): """Returns the image data after it has been processed by any normalization options in use. - This method also sets the attributes self.levelMin and self.levelMax - to indicate the range of data in the image.""" + """ if self.imageDisp is None: image = self.normalize(self.image) self.imageDisp = image - self.levelMin, self.levelMax = list(map(float, self.quickMinMax(self.imageDisp))) + self._imageLevels = self.quickMinMax(self.imageDisp) + self.levelMin = min([level[0] for level in self._imageLevels]) + self.levelMax = max([level[1] for level in self._imageLevels]) return self.imageDisp def close(self): """Closes the widget nicely, making sure to clear the graphics scene and release memory.""" - self.ui.roiPlot.close() - self.ui.graphicsView.close() - self.scene.clear() - del self.image - del self.imageDisp + self.clear() + self.imageDisp = None + self.imageItem.setParent(None) super(ImageView, self).close() self.setParent(None) @@ -469,17 +490,17 @@ def timeout(self): n = int(self.playRate * dt) if n != 0: self.lastPlayTime += (float(n)/self.playRate) - if self.currentIndex+n > self.image.shape[0]: + if self.currentIndex+n > self.image.shape[self.axes['t']]: self.play(0) self.jumpFrames(n) def setCurrentIndex(self, ind): """Set the currently displayed frame index.""" - self.currentIndex = np.clip(ind, 0, self.getProcessedImage().shape[self.axes['t']]-1) - self.updateImage() - self.ignoreTimeLine = True - self.timeLine.setValue(self.tVals[self.currentIndex]) - self.ignoreTimeLine = False + index = np.clip(ind, 0, self.getProcessedImage().shape[self.axes['t']]-1) + self.ignorePlaying = True + # Implicitly call timeLineChanged + self.timeLine.setValue(self.tVals[index]) + self.ignorePlaying = False def jumpFrames(self, n): """Move video frame ahead n frames (may be negative)""" @@ -527,13 +548,15 @@ def roiClicked(self): #self.ui.roiPlot.show() self.ui.roiPlot.setMouseEnabled(True, True) self.ui.splitter.setSizes([self.height()*0.6, self.height()*0.4]) - self.roiCurve.show() + for c in self.roiCurves: + c.show() self.roiChanged() self.ui.roiPlot.showAxis('left') else: self.roi.hide() self.ui.roiPlot.setMouseEnabled(False, False) - self.roiCurve.hide() + for c in self.roiCurves: + c.hide() self.ui.roiPlot.hideAxis('left') if self.hasTimeAxis(): @@ -557,36 +580,72 @@ def roiChanged(self): return image = self.getProcessedImage() - if image.ndim == 2: - axes = (0, 1) - elif image.ndim == 3: - axes = (1, 2) - else: + + # Extract image data from ROI + axes = (self.axes['x'], self.axes['y']) + + data, coords = self.roi.getArrayRegion(image.view(np.ndarray), self.imageItem, returnMappedCoords=True) + if data is None: return - - data, coords = self.roi.getArrayRegion(image.view(np.ndarray), self.imageItem, axes, returnMappedCoords=True) - if data is not None: - while data.ndim > 1: - data = data.mean(axis=1) - if image.ndim == 3: - self.roiCurve.setData(y=data, x=self.tVals) + + # Convert extracted data into 1D plot data + if self.axes['t'] is None: + # Average across y-axis of ROI + data = data.mean(axis=axes[1]) + if axes == (1,0): ## we're in row-major order mode -- there's probably a better way to do this slicing dynamically, but I've not figured it out yet. + coords = coords[:,0,:] - coords[:,0,0:1] + else: #default to old way + coords = coords[:,:,0] - coords[:,0:1,0] + xvals = (coords**2).sum(axis=0) ** 0.5 + else: + # Average data within entire ROI for each frame + data = data.mean(axis=max(axes)).mean(axis=min(axes)) + xvals = self.tVals + + # Handle multi-channel data + if data.ndim == 1: + plots = [(xvals, data, 'w')] + if data.ndim == 2: + if data.shape[1] == 1: + colors = 'w' else: - while coords.ndim > 2: - coords = coords[:,:,0] - coords = coords - coords[:,0,np.newaxis] - xvals = (coords**2).sum(axis=0) ** 0.5 - self.roiCurve.setData(y=data, x=xvals) + colors = 'rgbw' + plots = [] + for i in range(data.shape[1]): + d = data[:,i] + plots.append((xvals, d, colors[i])) + + # Update plot line(s) + while len(plots) < len(self.roiCurves): + c = self.roiCurves.pop() + c.scene().removeItem(c) + while len(plots) > len(self.roiCurves): + self.roiCurves.append(self.ui.roiPlot.plot()) + for i in range(len(plots)): + x, y, p = plots[i] + self.roiCurves[i].setData(x, y, pen=p) def quickMinMax(self, data): """ Estimate the min/max values of *data* by subsampling. + Returns [(min, max), ...] with one item per channel """ while data.size > 1e6: ax = np.argmax(data.shape) sl = [slice(None)] * data.ndim sl[ax] = slice(None, None, 2) - data = data[sl] - return nanmin(data), nanmax(data) + data = data[tuple(sl)] + + cax = self.axes['c'] + if cax is None: + if data.size == 0: + return [(0, 0)] + return [(float(nanmin(data)), float(nanmax(data)))] + else: + if data.size == 0: + return [(0, 0)] * data.shape[-1] + return [(float(nanmin(data.take(i, axis=cax))), + float(nanmax(data.take(i, axis=cax)))) for i in range(data.shape[-1])] def normalize(self, image): """ @@ -638,16 +697,13 @@ def normalize(self, image): return norm def timeLineChanged(self): - #(ind, time) = self.timeIndex(self.ui.timeSlider) - if self.ignoreTimeLine: - return - self.play(0) + if not self.ignorePlaying: + self.play(0) + (ind, time) = self.timeIndex(self.timeLine) if ind != self.currentIndex: self.currentIndex = ind self.updateImage() - #self.timeLine.setPos(time) - #self.emit(QtCore.SIGNAL('timeChanged'), ind, time) self.sigTimeChanged.emit(ind, time) def updateImage(self, autoHistogramRange=True): @@ -682,7 +738,7 @@ def timeIndex(self, slider): return (0,0) t = slider.value() - + xv = self.tVals if xv is None: ind = int(t) @@ -690,7 +746,7 @@ def timeIndex(self, slider): if len(xv) < 2: return (0,0) totTime = xv[-1] + (xv[-1]-xv[-2]) - inds = np.argwhere(xv < t) + inds = np.argwhere(xv <= t) if len(inds) < 1: return (0,t) ind = inds[-1,0] @@ -732,9 +788,11 @@ def export(self, fileName): def exportClicked(self): fileName = QtGui.QFileDialog.getSaveFileName() + if isinstance(fileName, tuple): + fileName = fileName[0] # Qt4/5 API difference if fileName == '': return - self.export(fileName) + self.export(str(fileName)) def buildMenu(self): self.menu = QtGui.QMenu() diff --git a/pyqtgraph/imageview/ImageViewTemplate.ui b/pyqtgraph/imageview/ImageViewTemplate.ui index 927bda30aa..ece77864c3 100644 --- a/pyqtgraph/imageview/ImageViewTemplate.ui +++ b/pyqtgraph/imageview/ImageViewTemplate.ui @@ -11,7 +11,7 @@
- Form + PyQtGraph diff --git a/pyqtgraph/imageview/ImageViewTemplate_pyqt.py b/pyqtgraph/imageview/ImageViewTemplate_pyqt.py index 8c9d563398..8a34c1d8a9 100644 --- a/pyqtgraph/imageview/ImageViewTemplate_pyqt.py +++ b/pyqtgraph/imageview/ImageViewTemplate_pyqt.py @@ -146,7 +146,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(_translate("Form", "Form", None)) + Form.setWindowTitle(_translate("Form", "PyQtGraph", None)) self.roiBtn.setText(_translate("Form", "ROI", None)) self.menuBtn.setText(_translate("Form", "Menu", None)) self.normGroup.setTitle(_translate("Form", "Normalization", None)) diff --git a/pyqtgraph/imageview/ImageViewTemplate_pyqt5.py b/pyqtgraph/imageview/ImageViewTemplate_pyqt5.py index 4b4009b6f0..87f3f2542e 100644 --- a/pyqtgraph/imageview/ImageViewTemplate_pyqt5.py +++ b/pyqtgraph/imageview/ImageViewTemplate_pyqt5.py @@ -41,15 +41,15 @@ def setupUi(self, Form): self.roiBtn.setCheckable(True) self.roiBtn.setObjectName("roiBtn") self.gridLayout.addWidget(self.roiBtn, 1, 1, 1, 1) - self.normBtn = QtWidgets.QPushButton(self.layoutWidget) + self.menuBtn = QtWidgets.QPushButton(self.layoutWidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(1) - sizePolicy.setHeightForWidth(self.normBtn.sizePolicy().hasHeightForWidth()) - self.normBtn.setSizePolicy(sizePolicy) - self.normBtn.setCheckable(True) - self.normBtn.setObjectName("normBtn") - self.gridLayout.addWidget(self.normBtn, 1, 2, 1, 1) + sizePolicy.setHeightForWidth(self.menuBtn.sizePolicy().hasHeightForWidth()) + self.menuBtn.setSizePolicy(sizePolicy) + self.menuBtn.setCheckable(True) + self.menuBtn.setObjectName("menuBtn") + self.gridLayout.addWidget(self.menuBtn, 1, 2, 1, 1) self.roiPlot = PlotWidget(self.splitter) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) @@ -134,9 +134,9 @@ def setupUi(self, Form): def retranslateUi(self, Form): _translate = QtCore.QCoreApplication.translate - Form.setWindowTitle(_translate("Form", "Form")) + Form.setWindowTitle(_translate("Form", "PyQtGraph")) self.roiBtn.setText(_translate("Form", "ROI")) - self.normBtn.setText(_translate("Form", "Norm")) + self.menuBtn.setText(_translate("Form", "Norm")) self.normGroup.setTitle(_translate("Form", "Normalization")) self.normSubtractRadio.setText(_translate("Form", "Subtract")) self.normDivideRadio.setText(_translate("Form", "Divide")) diff --git a/pyqtgraph/imageview/ImageViewTemplate_pyside.py b/pyqtgraph/imageview/ImageViewTemplate_pyside.py index 6d6c96322a..9980e2bae7 100644 --- a/pyqtgraph/imageview/ImageViewTemplate_pyside.py +++ b/pyqtgraph/imageview/ImageViewTemplate_pyside.py @@ -132,7 +132,7 @@ def setupUi(self, Form): QtCore.QMetaObject.connectSlotsByName(Form) def retranslateUi(self, Form): - Form.setWindowTitle(QtGui.QApplication.translate("Form", "Form", None, QtGui.QApplication.UnicodeUTF8)) + Form.setWindowTitle(QtGui.QApplication.translate("Form", "PyQtGraph", None, QtGui.QApplication.UnicodeUTF8)) self.roiBtn.setText(QtGui.QApplication.translate("Form", "ROI", None, QtGui.QApplication.UnicodeUTF8)) self.menuBtn.setText(QtGui.QApplication.translate("Form", "Menu", None, QtGui.QApplication.UnicodeUTF8)) self.normGroup.setTitle(QtGui.QApplication.translate("Form", "Normalization", None, QtGui.QApplication.UnicodeUTF8)) diff --git a/pyqtgraph/imageview/ImageViewTemplate_pyside2.py b/pyqtgraph/imageview/ImageViewTemplate_pyside2.py new file mode 100644 index 0000000000..cfe400c1d0 --- /dev/null +++ b/pyqtgraph/imageview/ImageViewTemplate_pyside2.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ImageViewTemplate.ui' +# +# Created: Sun Sep 18 19:17:41 2016 +# by: pyside2-uic running on PySide2 2.0.0~alpha0 +# +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore, QtGui, QtWidgets + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(726, 588) + self.gridLayout_3 = QtWidgets.QGridLayout(Form) + self.gridLayout_3.setContentsMargins(0, 0, 0, 0) + self.gridLayout_3.setSpacing(0) + self.gridLayout_3.setObjectName("gridLayout_3") + self.splitter = QtWidgets.QSplitter(Form) + self.splitter.setOrientation(QtCore.Qt.Vertical) + self.splitter.setObjectName("splitter") + self.layoutWidget = QtWidgets.QWidget(self.splitter) + self.layoutWidget.setObjectName("layoutWidget") + self.gridLayout = QtWidgets.QGridLayout(self.layoutWidget) + self.gridLayout.setSpacing(0) + self.gridLayout.setContentsMargins(0, 0, 0, 0) + self.gridLayout.setObjectName("gridLayout") + self.graphicsView = GraphicsView(self.layoutWidget) + self.graphicsView.setObjectName("graphicsView") + self.gridLayout.addWidget(self.graphicsView, 0, 0, 2, 1) + self.histogram = HistogramLUTWidget(self.layoutWidget) + self.histogram.setObjectName("histogram") + self.gridLayout.addWidget(self.histogram, 0, 1, 1, 2) + self.roiBtn = QtWidgets.QPushButton(self.layoutWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.roiBtn.sizePolicy().hasHeightForWidth()) + self.roiBtn.setSizePolicy(sizePolicy) + self.roiBtn.setCheckable(True) + self.roiBtn.setObjectName("roiBtn") + self.gridLayout.addWidget(self.roiBtn, 1, 1, 1, 1) + self.menuBtn = QtWidgets.QPushButton(self.layoutWidget) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(1) + sizePolicy.setHeightForWidth(self.menuBtn.sizePolicy().hasHeightForWidth()) + self.menuBtn.setSizePolicy(sizePolicy) + self.menuBtn.setObjectName("menuBtn") + self.gridLayout.addWidget(self.menuBtn, 1, 2, 1, 1) + self.roiPlot = PlotWidget(self.splitter) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.roiPlot.sizePolicy().hasHeightForWidth()) + self.roiPlot.setSizePolicy(sizePolicy) + self.roiPlot.setMinimumSize(QtCore.QSize(0, 40)) + self.roiPlot.setObjectName("roiPlot") + self.gridLayout_3.addWidget(self.splitter, 0, 0, 1, 1) + self.normGroup = QtWidgets.QGroupBox(Form) + self.normGroup.setObjectName("normGroup") + self.gridLayout_2 = QtWidgets.QGridLayout(self.normGroup) + self.gridLayout_2.setContentsMargins(0, 0, 0, 0) + self.gridLayout_2.setSpacing(0) + self.gridLayout_2.setObjectName("gridLayout_2") + self.normSubtractRadio = QtWidgets.QRadioButton(self.normGroup) + self.normSubtractRadio.setObjectName("normSubtractRadio") + self.gridLayout_2.addWidget(self.normSubtractRadio, 0, 2, 1, 1) + self.normDivideRadio = QtWidgets.QRadioButton(self.normGroup) + self.normDivideRadio.setChecked(False) + self.normDivideRadio.setObjectName("normDivideRadio") + self.gridLayout_2.addWidget(self.normDivideRadio, 0, 1, 1, 1) + self.label_5 = QtWidgets.QLabel(self.normGroup) + font = QtGui.QFont() + font.setWeight(75) + font.setBold(True) + self.label_5.setFont(font) + self.label_5.setObjectName("label_5") + self.gridLayout_2.addWidget(self.label_5, 0, 0, 1, 1) + self.label_3 = QtWidgets.QLabel(self.normGroup) + font = QtGui.QFont() + font.setWeight(75) + font.setBold(True) + self.label_3.setFont(font) + self.label_3.setObjectName("label_3") + self.gridLayout_2.addWidget(self.label_3, 1, 0, 1, 1) + self.label_4 = QtWidgets.QLabel(self.normGroup) + font = QtGui.QFont() + font.setWeight(75) + font.setBold(True) + self.label_4.setFont(font) + self.label_4.setObjectName("label_4") + self.gridLayout_2.addWidget(self.label_4, 2, 0, 1, 1) + self.normROICheck = QtWidgets.QCheckBox(self.normGroup) + self.normROICheck.setObjectName("normROICheck") + self.gridLayout_2.addWidget(self.normROICheck, 1, 1, 1, 1) + self.normXBlurSpin = QtWidgets.QDoubleSpinBox(self.normGroup) + self.normXBlurSpin.setObjectName("normXBlurSpin") + self.gridLayout_2.addWidget(self.normXBlurSpin, 2, 2, 1, 1) + self.label_8 = QtWidgets.QLabel(self.normGroup) + self.label_8.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.label_8.setObjectName("label_8") + self.gridLayout_2.addWidget(self.label_8, 2, 1, 1, 1) + self.label_9 = QtWidgets.QLabel(self.normGroup) + self.label_9.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.label_9.setObjectName("label_9") + self.gridLayout_2.addWidget(self.label_9, 2, 3, 1, 1) + self.normYBlurSpin = QtWidgets.QDoubleSpinBox(self.normGroup) + self.normYBlurSpin.setObjectName("normYBlurSpin") + self.gridLayout_2.addWidget(self.normYBlurSpin, 2, 4, 1, 1) + self.label_10 = QtWidgets.QLabel(self.normGroup) + self.label_10.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) + self.label_10.setObjectName("label_10") + self.gridLayout_2.addWidget(self.label_10, 2, 5, 1, 1) + self.normOffRadio = QtWidgets.QRadioButton(self.normGroup) + self.normOffRadio.setChecked(True) + self.normOffRadio.setObjectName("normOffRadio") + self.gridLayout_2.addWidget(self.normOffRadio, 0, 3, 1, 1) + self.normTimeRangeCheck = QtWidgets.QCheckBox(self.normGroup) + self.normTimeRangeCheck.setObjectName("normTimeRangeCheck") + self.gridLayout_2.addWidget(self.normTimeRangeCheck, 1, 3, 1, 1) + self.normFrameCheck = QtWidgets.QCheckBox(self.normGroup) + self.normFrameCheck.setObjectName("normFrameCheck") + self.gridLayout_2.addWidget(self.normFrameCheck, 1, 2, 1, 1) + self.normTBlurSpin = QtWidgets.QDoubleSpinBox(self.normGroup) + self.normTBlurSpin.setObjectName("normTBlurSpin") + self.gridLayout_2.addWidget(self.normTBlurSpin, 2, 6, 1, 1) + self.gridLayout_3.addWidget(self.normGroup, 1, 0, 1, 1) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + Form.setWindowTitle(QtWidgets.QApplication.translate("Form", "Form", None, -1)) + self.roiBtn.setText(QtWidgets.QApplication.translate("Form", "ROI", None, -1)) + self.menuBtn.setText(QtWidgets.QApplication.translate("Form", "Menu", None, -1)) + self.normGroup.setTitle(QtWidgets.QApplication.translate("Form", "Normalization", None, -1)) + self.normSubtractRadio.setText(QtWidgets.QApplication.translate("Form", "Subtract", None, -1)) + self.normDivideRadio.setText(QtWidgets.QApplication.translate("Form", "Divide", None, -1)) + self.label_5.setText(QtWidgets.QApplication.translate("Form", "Operation:", None, -1)) + self.label_3.setText(QtWidgets.QApplication.translate("Form", "Mean:", None, -1)) + self.label_4.setText(QtWidgets.QApplication.translate("Form", "Blur:", None, -1)) + self.normROICheck.setText(QtWidgets.QApplication.translate("Form", "ROI", None, -1)) + self.label_8.setText(QtWidgets.QApplication.translate("Form", "X", None, -1)) + self.label_9.setText(QtWidgets.QApplication.translate("Form", "Y", None, -1)) + self.label_10.setText(QtWidgets.QApplication.translate("Form", "T", None, -1)) + self.normOffRadio.setText(QtWidgets.QApplication.translate("Form", "Off", None, -1)) + self.normTimeRangeCheck.setText(QtWidgets.QApplication.translate("Form", "Time range", None, -1)) + self.normFrameCheck.setText(QtWidgets.QApplication.translate("Form", "Frame", None, -1)) + +from ..widgets.HistogramLUTWidget import HistogramLUTWidget +from ..widgets.PlotWidget import PlotWidget +from ..widgets.GraphicsView import GraphicsView diff --git a/pyqtgraph/metaarray/MetaArray.py b/pyqtgraph/metaarray/MetaArray.py index 7984f6a863..374c9acf80 100644 --- a/pyqtgraph/metaarray/MetaArray.py +++ b/pyqtgraph/metaarray/MetaArray.py @@ -12,10 +12,9 @@ import types, copy, threading, os, re import pickle -from functools import reduce import numpy as np from ..python2_3 import basestring -#import traceback + ## By default, the library will use HDF5 when writing files. ## This can be overridden by setting USE_HDF5 = False @@ -103,7 +102,7 @@ class MetaArray(object): since the actual values are described (name and units) in the column info for the first axis. """ - version = '2' + version = u'2' # Default hdf5 compression to use when writing # 'gzip' is widely available and somewhat slow @@ -358,9 +357,12 @@ def asarray(self): else: return np.array(self._data) - def __array__(self): + def __array__(self, dtype=None): ## supports np.array(metaarray_instance) - return self.asarray() + if dtype is None: + return self.asarray() + else: + return self.asarray().astype(dtype) def view(self, typ): ## deprecated; kept for backward compatibility @@ -741,14 +743,13 @@ def readFile(self, filename, **kwargs): ## decide which read function to use with open(filename, 'rb') as fd: magic = fd.read(8) - if magic == '\x89HDF\r\n\x1a\n': + if magic == b'\x89HDF\r\n\x1a\n': fd.close() self._readHDF5(filename, **kwargs) self._isHDF = True else: fd.seek(0) meta = MetaArray._readMeta(fd) - if not kwargs.get("readAllData", True): self._data = np.empty(meta['shape'], dtype=meta['type']) if 'version' in meta: @@ -767,7 +768,7 @@ def _readMeta(fd): """Read meta array from the top of a file. Read lines until a blank line is reached. This function should ideally work for ALL versions of MetaArray. """ - meta = '' + meta = u'' ## Read meta information until the first blank line while True: line = fd.readline().strip() @@ -777,7 +778,7 @@ def _readMeta(fd): ret = eval(meta) #print ret return ret - + def _readData1(self, fd, meta, mmap=False, **kwds): ## Read array data from the file descriptor for MetaArray v1 files ## read in axis values for any axis that specifies a length @@ -845,7 +846,7 @@ def _readData2(self, fd, meta, mmap=False, subset=None, **kwds): frames = [] frameShape = list(meta['shape']) frameShape[dynAxis] = 1 - frameSize = reduce(lambda a,b: a*b, frameShape) + frameSize = np.prod(frameShape) n = 0 while True: ## Extract one non-blank line @@ -887,10 +888,8 @@ def _readData2(self, fd, meta, mmap=False, subset=None, **kwds): newSubset = list(subset[:]) newSubset[dynAxis] = slice(dStart, dStop) if dStop > dStart: - #print n, data.shape, " => ", newSubset, data[tuple(newSubset)].shape frames.append(data[tuple(newSubset)].copy()) else: - #data = data[subset].copy() ## what's this for?? frames.append(data) n += inf['numFrames'] @@ -901,12 +900,8 @@ def _readData2(self, fd, meta, mmap=False, subset=None, **kwds): ax['values'] = np.array(xVals, dtype=ax['values_type']) del ax['values_len'] del ax['values_type'] - #subarr = subarr.view(subtype) - #subarr._info = meta['info'] self._info = meta['info'] self._data = subarr - #raise Exception() ## stress-testing - #return subarr def _readHDF5(self, fileName, readAllData=None, writable=False, **kargs): if 'close' in kargs and readAllData is None: ## for backward compatibility @@ -936,6 +931,10 @@ def _readHDF5(self, fileName, readAllData=None, writable=False, **kargs): f = h5py.File(fileName, mode) ver = f.attrs['MetaArray'] + try: + ver = ver.decode('utf-8') + except: + pass if ver > MetaArray.version: print("Warning: This file was written with MetaArray version %s, but you are using version %s. (Will attempt to read anyway)" % (str(ver), str(MetaArray.version))) meta = MetaArray.readHDF5Meta(f['info']) @@ -965,11 +964,6 @@ def _readHDF5Remote(self, fileName): ma = MetaArray._h5py_metaarray.MetaArray(file=fileName) self._data = ma.asarray()._getValue() self._info = ma._info._getValue() - #print MetaArray._hdf5Process - #import inspect - #print MetaArray, id(MetaArray), inspect.getmodule(MetaArray) - - @staticmethod def mapHDF5Array(data, writable=False): @@ -981,9 +975,6 @@ def mapHDF5Array(data, writable=False): if off is None: raise Exception("This dataset uses chunked storage; it can not be memory-mapped. (store using mappable=True)") return np.memmap(filename=data.file.filename, offset=off, dtype=data.dtype, shape=data.shape, mode=mode) - - - @staticmethod def readHDF5Meta(root, mmap=False): @@ -992,6 +983,8 @@ def readHDF5Meta(root, mmap=False): ## Pull list of values from attributes and child objects for k in root.attrs: val = root.attrs[k] + if isinstance(val, bytes): + val = val.decode() if isinstance(val, basestring): ## strings need to be re-evaluated to their original types try: val = eval(val) @@ -1012,6 +1005,10 @@ def readHDF5Meta(root, mmap=False): data[k] = val typ = root.attrs['_metaType_'] + try: + typ = typ.decode('utf-8') + except: + pass del data['_metaType_'] if typ == 'dict': @@ -1025,20 +1022,21 @@ def readHDF5Meta(root, mmap=False): return d2 else: raise Exception("Don't understand metaType '%s'" % typ) - def write(self, fileName, **opts): """Write this object to a file. The object can be restored by calling MetaArray(file=fileName) opts: appendAxis: the name (or index) of the appendable axis. Allows the array to grow. + appendKeys: a list of keys (other than "values") for metadata to append to on the appendable axis. compression: None, 'gzip' (good compression), 'lzf' (fast compression), etc. chunks: bool or tuple specifying chunk shape - """ - - if USE_HDF5 and HAVE_HDF5: + """ + if USE_HDF5 is False: + return self.writeMa(fileName, **opts) + elif HAVE_HDF5 is True: return self.writeHDF5(fileName, **opts) else: - return self.writeMa(fileName, **opts) + raise Exception("h5py is required for writing .ma hdf5 files, but it could not be imported.") def writeMeta(self, fileName): """Used to re-write meta info to the given file. @@ -1051,7 +1049,6 @@ def writeMeta(self, fileName): self.writeHDF5Meta(f, 'info', self._info) f.close() - def writeHDF5(self, fileName, **opts): ## default options for writing datasets comp = self.defaultCompression @@ -1087,8 +1084,7 @@ def writeHDF5(self, fileName, **opts): ## update options if they were passed in for k in dsOpts: if k in opts: - dsOpts[k] = opts[k] - + dsOpts[k] = opts[k] ## If mappable is in options, it disables chunking/compression if opts.get('mappable', False): @@ -1096,7 +1092,6 @@ def writeHDF5(self, fileName, **opts): 'chunks': None, 'compression': None } - ## set maximum shape to allow expansion along appendAxis append = False @@ -1125,14 +1120,19 @@ def writeHDF5(self, fileName, **opts): data[tuple(sl)] = self.view(np.ndarray) ## add axis values if they are present. + axKeys = ["values"] + axKeys.extend(opts.get("appendKeys", [])) axInfo = f['info'][str(ax)] - if 'values' in axInfo: - v = axInfo['values'] - v2 = self._info[ax]['values'] - shape = list(v.shape) - shape[0] += v2.shape[0] - v.resize(shape) - v[-v2.shape[0]:] = v2 + for key in axKeys: + if key in axInfo: + v = axInfo[key] + v2 = self._info[ax][key] + shape = list(v.shape) + shape[0] += v2.shape[0] + v.resize(shape) + v[-v2.shape[0]:] = v2 + else: + raise TypeError('Cannot append to axis info key "%s"; this key is not present in the target file.' % key) f.close() else: f = h5py.File(fileName, 'w') @@ -1294,7 +1294,7 @@ def writeCsv(self, fileName=None): #frames = [] #frameShape = list(meta['shape']) #frameShape[dynAxis] = 1 - #frameSize = reduce(lambda a,b: a*b, frameShape) + #frameSize = np.prod(frameShape) #n = 0 #while True: ### Extract one non-blank line diff --git a/pyqtgraph/multiprocess/__init__.py b/pyqtgraph/multiprocess/__init__.py index 843b42a308..32a250cb8a 100644 --- a/pyqtgraph/multiprocess/__init__.py +++ b/pyqtgraph/multiprocess/__init__.py @@ -21,4 +21,4 @@ from .processes import * from .parallelizer import Parallelize, CanceledError -from .remoteproxy import proxy \ No newline at end of file +from .remoteproxy import proxy, ClosedError, NoResultError diff --git a/pyqtgraph/multiprocess/bootstrap.py b/pyqtgraph/multiprocess/bootstrap.py index bb71a7035c..b986836733 100644 --- a/pyqtgraph/multiprocess/bootstrap.py +++ b/pyqtgraph/multiprocess/bootstrap.py @@ -13,16 +13,35 @@ #print "key:", ' '.join([str(ord(x)) for x in authkey]) path = opts.pop('path', None) if path is not None: - ## rewrite sys.path without assigning a new object--no idea who already has a reference to the existing list. - while len(sys.path) > 0: - sys.path.pop() - sys.path.extend(path) + if isinstance(path, str): + # if string, just insert this into the path + sys.path.insert(0, path) + else: + # if list, then replace the entire sys.path + ## modify sys.path in place--no idea who already has a reference to the existing list. + while len(sys.path) > 0: + sys.path.pop() + sys.path.extend(path) + + pyqtapis = opts.pop('pyqtapis', None) + if pyqtapis is not None: + import sip + for k,v in pyqtapis.items(): + sip.setapi(k, v) - if opts.pop('pyside', False): + qt_lib = opts.pop('qt_lib', None) + if qt_lib == 'PySide': import PySide - + elif qt_lib == 'PySide2': + import PySide2 + elif qt_lib == 'PyQt5': + import PyQt5 targetStr = opts.pop('targetStr') - target = pickle.loads(targetStr) ## unpickling the target should import everything we need + try: + target = pickle.loads(targetStr) ## unpickling the target should import everything we need + except: + print("Current sys.path:", sys.path) + raise target(**opts) ## Send all other options to the target function sys.exit(0) diff --git a/pyqtgraph/multiprocess/parallelizer.py b/pyqtgraph/multiprocess/parallelizer.py index 934bc6d07a..b0f064bdcf 100644 --- a/pyqtgraph/multiprocess/parallelizer.py +++ b/pyqtgraph/multiprocess/parallelizer.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import os, sys, time, multiprocessing, re from .processes import ForkedProcess from .remoteproxy import ClosedError @@ -101,7 +102,10 @@ def __exit__(self, *exc_info): else: ## parent if self.showProgress: - self.progressDlg.__exit__(None, None, None) + try: + self.progressDlg.__exit__(None, None, None) + except Exception: + pass def runSerial(self): if self.showProgress: @@ -192,6 +196,8 @@ def runParallel(self): finally: if self.showProgress: self.progressDlg.__exit__(None, None, None) + for ch in self.childs: + ch.join() if len(self.exitCodes) < len(self.childs): raise Exception("Parallelizer started %d processes but only received exit codes from %d." % (len(self.childs), len(self.exitCodes))) for code in self.exitCodes: @@ -208,14 +214,14 @@ def suggestedWorkerCount(): try: cores = {} pid = None - - for line in open('/proc/cpuinfo'): - m = re.match(r'physical id\s+:\s+(\d+)', line) - if m is not None: - pid = m.groups()[0] - m = re.match(r'cpu cores\s+:\s+(\d+)', line) - if m is not None: - cores[pid] = int(m.groups()[0]) + with open('/proc/cpuinfo') as fd: + for line in fd: + m = re.match(r'physical id\s+:\s+(\d+)', line) + if m is not None: + pid = m.groups()[0] + m = re.match(r'cpu cores\s+:\s+(\d+)', line) + if m is not None: + cores[pid] = int(m.groups()[0]) return sum(cores.values()) except: return multiprocessing.cpu_count() @@ -240,7 +246,7 @@ def __init__(self, parallelizer, process, tasks, kwds): self.proc = process self.par = parallelizer self.tasks = tasks - for k, v in kwds.iteritems(): + for k, v in kwds.items(): setattr(self, k, v) def __iter__(self): diff --git a/pyqtgraph/multiprocess/processes.py b/pyqtgraph/multiprocess/processes.py index c7e4a80c1d..6e815edcc5 100644 --- a/pyqtgraph/multiprocess/processes.py +++ b/pyqtgraph/multiprocess/processes.py @@ -1,4 +1,4 @@ -import subprocess, atexit, os, sys, time, random, socket, signal +import subprocess, atexit, os, sys, time, random, socket, signal, inspect import multiprocessing.connection try: import cPickle as pickle @@ -6,7 +6,7 @@ import pickle from .remoteproxy import RemoteEventHandler, ClosedError, NoResultError, LocalObjectProxy, ObjectProxy -from ..Qt import USE_PYSIDE +from ..Qt import QT_LIB from ..util import cprint # color printing for debugging @@ -39,7 +39,7 @@ class Process(RemoteEventHandler): """ _process_count = 1 # just used for assigning colors to each process for debugging - def __init__(self, name=None, target=None, executable=None, copySysPath=True, debug=False, timeout=20, wrapStdout=None): + def __init__(self, name=None, target=None, executable=None, copySysPath=True, debug=False, timeout=20, wrapStdout=None, pyqtapis=None): """ ============== ============================================================= **Arguments:** @@ -47,10 +47,12 @@ def __init__(self, name=None, target=None, executable=None, copySysPath=True, de from the remote process. target Optional function to call after starting remote process. By default, this is startEventLoop(), which causes the remote - process to process requests from the parent process until it + process to handle requests from the parent process until it is asked to quit. If you wish to specify a different target, it must be picklable (bound methods are not). - copySysPath If True, copy the contents of sys.path to the remote process + copySysPath If True, copy the contents of sys.path to the remote process. + If False, then only the path required to import pyqtgraph is + added. debug If True, print detailed information about communication with the child process. wrapStdout If True (default on windows) then stdout and stderr from the @@ -59,6 +61,8 @@ def __init__(self, name=None, target=None, executable=None, copySysPath=True, de for a python bug: http://bugs.python.org/issue3905 but has the side effect that child output is significantly delayed relative to the parent output. + pyqtapis Optional dictionary of PyQt API version numbers to set before + importing pyqtgraph in the remote process. ============== ============================================================= """ if target is None: @@ -82,7 +86,13 @@ def __init__(self, name=None, target=None, executable=None, copySysPath=True, de port = l.address[1] ## start remote process, instruct it to run target function - sysPath = sys.path if copySysPath else None + if copySysPath: + sysPath = sys.path + else: + # what path do we need to make target importable? + mod = inspect.getmodule(target) + modroot = sys.modules[mod.__name__.split('.')[0]] + sysPath = os.path.abspath(os.path.join(os.path.dirname(modroot.__file__), '..')) bootstrap = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bootstrap.py')) self.debugMsg('Starting child process (%s %s)' % (executable, bootstrap)) @@ -121,8 +131,9 @@ def __init__(self, name=None, target=None, executable=None, copySysPath=True, de ppid=pid, targetStr=targetStr, path=sysPath, - pyside=USE_PYSIDE, - debug=procDebug + qt_lib=QT_LIB, + debug=procDebug, + pyqtapis=pyqtapis, ) pickle.dump(data, self.proc.stdin) self.proc.stdin.close() @@ -154,6 +165,15 @@ def join(self, timeout=10): if timeout is not None and time.time() - start > timeout: raise Exception('Timed out waiting for remote process to end.') time.sleep(0.05) + self.conn.close() + + # Close remote polling threads, otherwise they will spin continuously + if hasattr(self, "_stdoutForwarder"): + self._stdoutForwarder.finish.set() + self._stderrForwarder.finish.set() + self._stdoutForwarder.join() + self._stderrForwarder.join() + self.debugMsg('Child process exited. (%d)' % self.proc.returncode) def debugMsg(self, msg, *args): @@ -182,7 +202,8 @@ def startEventLoop(name, port, authkey, ppid, debug=False): HANDLER.processRequests() # exception raised when the loop should exit time.sleep(0.01) except ClosedError: - break + HANDLER.debugMsg('Exiting server loop.') + sys.exit(0) class ForkedProcess(RemoteEventHandler): @@ -238,7 +259,7 @@ def __init__(self, name=None, target=0, preProxy=None, randomReseed=True): proxyIDs = {} if preProxy is not None: - for k, v in preProxy.iteritems(): + for k, v in preProxy.items(): proxyId = LocalObjectProxy.registerObject(v) proxyIDs[k] = proxyId @@ -287,7 +308,7 @@ def excepthook(*args): RemoteEventHandler.__init__(self, remoteConn, name+'_child', pid=ppid) self.forkedProxies = {} - for name, proxyId in proxyIDs.iteritems(): + for name, proxyId in proxyIDs.items(): self.forkedProxies[name] = ObjectProxy(ppid, proxyId=proxyId, typeStr=repr(preProxy[name])) if target is not None: @@ -321,9 +342,15 @@ def join(self, timeout=10): #os.kill(pid, 9) try: self.close(callSync='sync', timeout=timeout, noCleanup=True) ## ask the child process to exit and require that it return a confirmation. - os.waitpid(self.childPid, 0) except IOError: ## probably remote process has already quit pass + + try: + os.waitpid(self.childPid, 0) + except OSError: ## probably remote process has already quit + pass + + self.conn.close() # don't leak file handles! self.hasJoined = True def kill(self): @@ -454,24 +481,24 @@ def __init__(self, input, output, color): self.lock = threading.Lock() self.daemon = True self.color = color + self.finish = threading.Event() self.start() def run(self): - if self.output == 'stdout': - while True: + if self.output == 'stdout' and self.color is not False: + while not self.finish.is_set(): line = self.input.readline() with self.lock: cprint.cout(self.color, line, -1) - elif self.output == 'stderr': - while True: + elif self.output == 'stderr' and self.color is not False: + while not self.finish.is_set(): line = self.input.readline() with self.lock: cprint.cerr(self.color, line, -1) else: - while True: + if isinstance(self.output, str): + self.output = getattr(sys, self.output) + while not self.finish.is_set(): line = self.input.readline() with self.lock: - self.output.write(line) - - - + self.output.write(line.decode('utf8')) diff --git a/pyqtgraph/multiprocess/remoteproxy.py b/pyqtgraph/multiprocess/remoteproxy.py index 208e17f424..f0d993cb68 100644 --- a/pyqtgraph/multiprocess/remoteproxy.py +++ b/pyqtgraph/multiprocess/remoteproxy.py @@ -1,6 +1,7 @@ import os, time, sys, traceback, weakref import numpy as np import threading +import warnings try: import __builtin__ as builtins import cPickle as pickle @@ -21,6 +22,9 @@ class NoResultError(Exception): because the call has not yet returned.""" pass +class RemoteExceptionWarning(UserWarning): + """Emitted when a request to a remote object results in an Exception """ + pass class RemoteEventHandler(object): """ @@ -419,7 +423,7 @@ def send(self, request, opts=None, reqId=None, callSync='sync', timeout=10, retu if opts is None: opts = {} - assert callSync in ['off', 'sync', 'async'], 'callSync must be one of "off", "sync", or "async"' + assert callSync in ['off', 'sync', 'async'], 'callSync must be one of "off", "sync", or "async" (got %r)' % callSync if reqId is None: if callSync != 'off': ## requested return value; use the next available request ID reqId = self.nextRequestId @@ -454,7 +458,7 @@ def send(self, request, opts=None, reqId=None, callSync='sync', timeout=10, retu ## follow up by sending byte messages if byteData is not None: for obj in byteData: ## Remote process _must_ be prepared to read the same number of byte messages! - self.conn.send_bytes(obj) + self.conn.send_bytes(bytes(obj)) self.debugMsg(' sent %d byte messages', len(byteData)) self.debugMsg(' call sync: %s', callSync) @@ -466,10 +470,7 @@ def send(self, request, opts=None, reqId=None, callSync='sync', timeout=10, retu return req if callSync == 'sync': - try: - return req.result() - except NoResultError: - return req + return req.result() def close(self, callSync='off', noCleanup=False, **kwds): try: @@ -502,9 +503,9 @@ def getResult(self, reqId): #print ''.join(result) exc, excStr = result if exc is not None: - print("===== Remote process raised exception on request: =====") - print(''.join(excStr)) - print("===== Local Traceback to request follows: =====") + warnings.warn("===== Remote process raised exception on request: =====", RemoteExceptionWarning) + warnings.warn(''.join(excStr), RemoteExceptionWarning) + warnings.warn("===== Local Traceback to request follows: =====", RemoteExceptionWarning) raise exc else: print(''.join(excStr)) @@ -548,7 +549,7 @@ def callObj(self, obj, args, kwds, **opts): if autoProxy is True: args = [self.autoProxy(v, noProxyTypes) for v in args] - for k, v in kwds.iteritems(): + for k, v in kwds.items(): opts[k] = self.autoProxy(v, noProxyTypes) byteMsgs = [] @@ -572,6 +573,10 @@ def registerProxy(self, proxy): self.proxies[ref] = proxy._proxyId def deleteProxy(self, ref): + if self.send is None: + # this can happen during shutdown + return + with self.proxyLock: proxyId = self.proxies.pop(ref) diff --git a/pyqtgraph/numpy_fix.py b/pyqtgraph/numpy_fix.py deleted file mode 100644 index 2fa8ef1f0e..0000000000 --- a/pyqtgraph/numpy_fix.py +++ /dev/null @@ -1,22 +0,0 @@ -try: - import numpy as np - - ## Wrap np.concatenate to catch and avoid a segmentation fault bug - ## (numpy trac issue #2084) - if not hasattr(np, 'concatenate_orig'): - np.concatenate_orig = np.concatenate - def concatenate(vals, *args, **kwds): - """Wrapper around numpy.concatenate (see pyqtgraph/numpy_fix.py)""" - dtypes = [getattr(v, 'dtype', None) for v in vals] - names = [getattr(dt, 'names', None) for dt in dtypes] - if len(dtypes) < 2 or all([n is None for n in names]): - return np.concatenate_orig(vals, *args, **kwds) - if any([dt != dtypes[0] for dt in dtypes[1:]]): - raise TypeError("Cannot concatenate structured arrays of different dtype.") - return np.concatenate_orig(vals, *args, **kwds) - - np.concatenate = concatenate - -except ImportError: - pass - diff --git a/pyqtgraph/opengl/GLViewWidget.py b/pyqtgraph/opengl/GLViewWidget.py index e0fee046bd..f123b15179 100644 --- a/pyqtgraph/opengl/GLViewWidget.py +++ b/pyqtgraph/opengl/GLViewWidget.py @@ -1,4 +1,4 @@ -from ..Qt import QtCore, QtGui, QtOpenGL, USE_PYQT5 +from ..Qt import QtCore, QtGui, QtOpenGL, QT_LIB from OpenGL.GL import * import OpenGL.GL.framebufferobjects as glfbo import numpy as np @@ -16,9 +16,13 @@ class GLViewWidget(QtOpenGL.QGLWidget): - Axis/grid display - Export options + + High-DPI displays: Qt5 should automatically detect the correct resolution. + For Qt4, specify the ``devicePixelRatio`` argument when initializing the + widget (usually this value is 1-2). """ - def __init__(self, parent=None): + def __init__(self, parent=None, devicePixelRatio=None): global ShareWidget if ShareWidget is None: @@ -37,6 +41,7 @@ def __init__(self, parent=None): 'azimuth': 45, ## camera's azimuthal angle in degrees ## (rotation around z-axis 0 points along x-axis) 'viewport': None, ## glViewport params; None == whole widget + 'devicePixelRatio': devicePixelRatio, } self.setBackgroundColor('k') self.items = [] @@ -79,10 +84,21 @@ def setBackgroundColor(self, *args, **kwds): def getViewport(self): vp = self.opts['viewport'] + dpr = self.devicePixelRatio() if vp is None: - return (0, 0, self.width(), self.height()) + return (0, 0, int(self.width() * dpr), int(self.height() * dpr)) + else: + return tuple([int(x * dpr) for x in vp]) + + def devicePixelRatio(self): + dpr = self.opts['devicePixelRatio'] + if dpr is not None: + return dpr + + if hasattr(QtOpenGL.QGLWidget, 'devicePixelRatio'): + return QtOpenGL.QGLWidget.devicePixelRatio(self) else: - return vp + return 1.0 def resizeGL(self, w, h): pass @@ -97,9 +113,9 @@ def setProjection(self, region=None): glMultMatrixf(a.transpose()) def projectionMatrix(self, region=None): - # Xw = (Xnd + 1) * width/2 + X if region is None: - region = (0, 0, self.width(), self.height()) + dpr = self.devicePixelRatio() + region = (0, 0, self.width() * dpr, self.height() * dpr) x0, y0, w, h = self.getViewport() dist = self.opts['distance'] @@ -110,8 +126,6 @@ def projectionMatrix(self, region=None): r = nearClip * np.tan(fov * 0.5 * np.pi / 180.) t = r * h / w - # convert screen coordinates (region) to normalized device coordinates - # Xnd = (Xw - X0) * 2/width - 1 ## Note that X0 and width in these equations must be the values used in viewport left = r * ((region[0]-x0) * (2.0/w) - 1) right = r * ((region[0]+region[2]-x0) * (2.0/w) - 1) @@ -222,6 +236,8 @@ def drawItemTree(self, item=None, useItemNames=False): glPopMatrix() def setCameraPosition(self, pos=None, distance=None, elevation=None, azimuth=None): + if pos is not None: + self.opts['center'] = pos if distance is not None: self.opts['distance'] = distance if elevation is not None: @@ -230,8 +246,6 @@ def setCameraPosition(self, pos=None, distance=None, elevation=None, azimuth=Non self.opts['azimuth'] = azimuth self.update() - - def cameraPosition(self): """Return current position of camera based on center, dist, elevation, and azimuth""" center = self.opts['center'] @@ -250,24 +264,41 @@ def cameraPosition(self): def orbit(self, azim, elev): """Orbits the camera around the center position. *azim* and *elev* are given in degrees.""" self.opts['azimuth'] += azim - #self.opts['elevation'] += elev self.opts['elevation'] = np.clip(self.opts['elevation'] + elev, -90, 90) self.update() - def pan(self, dx, dy, dz, relative=False): + def pan(self, dx, dy, dz, relative='global'): """ Moves the center (look-at) position while holding the camera in place. - If relative=True, then the coordinates are interpreted such that x - if in the global xy plane and points to the right side of the view, y is - in the global xy plane and orthogonal to x, and z points in the global z - direction. Distances are scaled roughly such that a value of 1.0 moves + ============== ======================================================= + **Arguments:** + *dx* Distance to pan in x direction + *dy* Distance to pan in y direction + *dx* Distance to pan in z direction + *relative* String that determines the direction of dx,dy,dz. + If "global", then the global coordinate system is used. + If "view", then the z axis is aligned with the view + direction, and x and y axes are inthe plane of the + view: +x points right, +y points up. + If "view-upright", then x is in the global xy plane and + points to the right side of the view, y is in the + global xy plane and orthogonal to x, and z points in + the global z direction. + ============== ======================================================= + + Distances are scaled roughly such that a value of 1.0 moves by one pixel on screen. + Prior to version 0.11, *relative* was expected to be either True (x-aligned) or + False (global). These values are deprecated but still recognized. """ - if not relative: + # for backward compatibility: + relative = {True: "view-upright", False: "global"}.get(relative, relative) + + if relative == 'global': self.opts['center'] += QtGui.QVector3D(dx, dy, dz) - else: + elif relative == 'view-upright': cPos = self.cameraPosition() cVec = self.opts['center'] - cPos dist = cVec.length() ## distance from camera to center @@ -277,6 +308,21 @@ def pan(self, dx, dy, dz, relative=False): xVec = QtGui.QVector3D.crossProduct(zVec, cVec).normalized() yVec = QtGui.QVector3D.crossProduct(xVec, zVec).normalized() self.opts['center'] = self.opts['center'] + xVec * xScale * dx + yVec * xScale * dy + zVec * xScale * dz + elif relative == 'view': + # pan in plane of camera + elev = np.radians(self.opts['elevation']) + azim = np.radians(self.opts['azimuth']) + fov = np.radians(self.opts['fov']) + dist = (self.opts['center'] - self.cameraPosition()).length() + fov_factor = np.tan(fov / 2) * 2 + scale_factor = dist * fov_factor / self.width() + z = scale_factor * np.cos(elev) * dy + x = scale_factor * (np.sin(azim) * dx - np.sin(elev) * np.cos(azim) * dy) + y = scale_factor * (np.cos(azim) * dx + np.sin(elev) * np.sin(azim) * dy) + self.opts['center'] += QtGui.QVector3D(x, -y, z) + else: + raise ValueError("relative argument must be global, view, or view-upright") + self.update() def pixelSize(self, pos): @@ -301,13 +347,15 @@ def mouseMoveEvent(self, ev): self.mousePos = ev.pos() if ev.buttons() == QtCore.Qt.LeftButton: - self.orbit(-diff.x(), diff.y()) - #print self.opts['azimuth'], self.opts['elevation'] + if (ev.modifiers() & QtCore.Qt.ControlModifier): + self.pan(diff.x(), diff.y(), 0, relative='view') + else: + self.orbit(-diff.x(), diff.y()) elif ev.buttons() == QtCore.Qt.MidButton: if (ev.modifiers() & QtCore.Qt.ControlModifier): - self.pan(diff.x(), 0, diff.y(), relative=True) + self.pan(diff.x(), 0, diff.y(), relative='view-upright') else: - self.pan(diff.x(), diff.y(), 0, relative=True) + self.pan(diff.x(), diff.y(), 0, relative='view-upright') def mouseReleaseEvent(self, ev): pass @@ -325,7 +373,7 @@ def mouseReleaseEvent(self, ev): def wheelEvent(self, ev): delta = 0 - if not USE_PYQT5: + if QT_LIB in ['PyQt4', 'PySide']: delta = ev.delta() else: delta = ev.angleDelta().x() @@ -379,15 +427,13 @@ def evalKeyState(self): def checkOpenGLVersion(self, msg): ## Only to be called from within exception handler. ver = glGetString(GL_VERSION).split()[0] - if int(ver.split('.')[0]) < 2: + if int(ver.split(b'.')[0]) < 2: from .. import debug - pyqtgraph.debug.printExc() + debug.printExc() raise Exception(msg + " The original exception is printed above; however, pyqtgraph requires OpenGL version 2.0 or greater for many of its 3D features and your OpenGL version is %s. Installing updated display drivers may resolve this issue." % ver) else: raise - - def readQImage(self): """ Read the current buffer pixels out as a QImage. @@ -411,7 +457,6 @@ def readQImage(self): img = fn.makeQImage(pixels, transpose=False) return img - def renderToArray(self, size, format=GL_BGRA, type=GL_UNSIGNED_BYTE, textureSize=1024, padding=256): w,h = map(int, size) @@ -450,6 +495,7 @@ def renderToArray(self, size, format=GL_BGRA, type=GL_UNSIGNED_BYTE, textureSize glfbo.glFramebufferTexture2D(glfbo.GL_FRAMEBUFFER, glfbo.GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0) self.paintGL(region=(x, h-y-h2, w2, h2), viewport=(0, 0, w2, h2)) # only render sub-region + glBindTexture(GL_TEXTURE_2D, tex) # fixes issue #366 ## read texture back to array data = glGetTexImage(GL_TEXTURE_2D, 0, format, type) diff --git a/pyqtgraph/opengl/MeshData.py b/pyqtgraph/opengl/MeshData.py index f83fcdf650..95ba88af06 100644 --- a/pyqtgraph/opengl/MeshData.py +++ b/pyqtgraph/opengl/MeshData.py @@ -371,7 +371,7 @@ def vertexFaces(self): #pass def _computeEdges(self): - if not self.hasFaceIndexedData: + if not self.hasFaceIndexedData(): ## generate self._edges from self._faces nf = len(self._faces) edges = np.empty(nf*3, dtype=[('i', np.uint, 2)]) @@ -485,7 +485,7 @@ def cylinder(rows, cols, radius=[1.0, 1.0], length=1.0, offset=False): if isinstance(radius, int): radius = [radius, radius] # convert to list ## compute vertexes - th = np.linspace(2 * np.pi, 0, cols).reshape(1, cols) + th = np.linspace(2 * np.pi, (2 * np.pi)/cols, cols).reshape(1, cols) r = np.linspace(radius[0],radius[1],num=rows+1, endpoint=True).reshape(rows+1, 1) # radius as a function of z verts[...,2] = np.linspace(0, length, num=rows+1, endpoint=True).reshape(rows+1, 1) # z if offset: diff --git a/pyqtgraph/opengl/glInfo.py b/pyqtgraph/opengl/glInfo.py index 84346d81a7..0c3e758a88 100644 --- a/pyqtgraph/opengl/glInfo.py +++ b/pyqtgraph/opengl/glInfo.py @@ -6,10 +6,10 @@ class GLTest(QtOpenGL.QGLWidget): def __init__(self): QtOpenGL.QGLWidget.__init__(self) self.makeCurrent() - print("GL version:" + glGetString(GL_VERSION)) + print("GL version:" + glGetString(GL_VERSION).decode("utf-8")) print("MAX_TEXTURE_SIZE: %d" % glGetIntegerv(GL_MAX_TEXTURE_SIZE)) print("MAX_3D_TEXTURE_SIZE: %d" % glGetIntegerv(GL_MAX_3D_TEXTURE_SIZE)) - print("Extensions: " + glGetString(GL_EXTENSIONS)) + print("Extensions: " + glGetString(GL_EXTENSIONS).decode("utf-8").replace(" ", "\n")) GLTest() diff --git a/pyqtgraph/opengl/items/GLBarGraphItem.py b/pyqtgraph/opengl/items/GLBarGraphItem.py index b3060dc9ca..42d05fb772 100644 --- a/pyqtgraph/opengl/items/GLBarGraphItem.py +++ b/pyqtgraph/opengl/items/GLBarGraphItem.py @@ -8,7 +8,7 @@ def __init__(self, pos, size): pos is (...,3) array of the bar positions (the corner of each bar) size is (...,3) array of the sizes of each bar """ - nCubes = reduce(lambda a,b: a*b, pos.shape[:-1]) + nCubes = np.prod(pos.shape[:-1]) cubeVerts = np.mgrid[0:2,0:2,0:2].reshape(3,8).transpose().reshape(1,8,3) cubeFaces = np.array([ [0,1,2], [3,2,1], @@ -22,8 +22,5 @@ def __init__(self, pos, size): verts = cubeVerts * size + pos faces = cubeFaces + (np.arange(nCubes) * 8).reshape(nCubes,1,1) md = MeshData(verts.reshape(nCubes*8,3), faces.reshape(nCubes*12,3)) - - GLMeshItem.__init__(self, meshdata=md, shader='shaded', smooth=False) - - \ No newline at end of file + GLMeshItem.__init__(self, meshdata=md, shader='shaded', smooth=False) diff --git a/pyqtgraph/opengl/items/GLGridItem.py b/pyqtgraph/opengl/items/GLGridItem.py index 4d6bc9d6d1..9dcff070ef 100644 --- a/pyqtgraph/opengl/items/GLGridItem.py +++ b/pyqtgraph/opengl/items/GLGridItem.py @@ -3,6 +3,7 @@ from OpenGL.GL import * from .. GLGraphicsItem import GLGraphicsItem from ... import QtGui +from ... import functions as fn __all__ = ['GLGridItem'] @@ -10,10 +11,10 @@ class GLGridItem(GLGraphicsItem): """ **Bases:** :class:`GLGraphicsItem ` - Displays a wire-grame grid. + Displays a wire-frame grid. """ - def __init__(self, size=None, color=None, antialias=True, glOptions='translucent'): + def __init__(self, size=None, color=(255, 255, 255, 76.5), antialias=True, glOptions='translucent'): GLGraphicsItem.__init__(self) self.setGLOptions(glOptions) self.antialias = antialias @@ -21,6 +22,7 @@ def __init__(self, size=None, color=None, antialias=True, glOptions='translucent size = QtGui.QVector3D(20,20,1) self.setSize(size=size) self.setSpacing(1, 1, 1) + self.setColor(color) def setSize(self, x=None, y=None, z=None, size=None): """ @@ -52,6 +54,14 @@ def setSpacing(self, x=None, y=None, z=None, spacing=None): def spacing(self): return self.__spacing[:] + def setColor(self, color): + """Set the color of the grid. Arguments are the same as those accepted by functions.mkColor()""" + self.__color = fn.Color(color) + self.update() + + def color(self): + return self.__color + def paint(self): self.setupGLState() @@ -66,8 +76,8 @@ def paint(self): x,y,z = self.size() xs,ys,zs = self.spacing() xvals = np.arange(-x/2., x/2. + xs*0.001, xs) - yvals = np.arange(-y/2., y/2. + ys*0.001, ys) - glColor4f(1, 1, 1, .3) + yvals = np.arange(-y/2., y/2. + ys*0.001, ys) + glColor4f(*self.color().glColor()) for x in xvals: glVertex3f(x, yvals[0], 0) glVertex3f(x, yvals[-1], 0) diff --git a/pyqtgraph/opengl/items/GLImageItem.py b/pyqtgraph/opengl/items/GLImageItem.py index 59ddaf6f57..d72448a133 100644 --- a/pyqtgraph/opengl/items/GLImageItem.py +++ b/pyqtgraph/opengl/items/GLImageItem.py @@ -29,8 +29,11 @@ def __init__(self, data, smooth=False, glOptions='translucent'): GLGraphicsItem.__init__(self) self.setData(data) self.setGLOptions(glOptions) + self.texture = None def initializeGL(self): + if self.texture is not None: + return glEnable(GL_TEXTURE_2D) self.texture = glGenTextures(1) diff --git a/pyqtgraph/opengl/items/GLLinePlotItem.py b/pyqtgraph/opengl/items/GLLinePlotItem.py index f5cb7545ce..2daf78ba76 100644 --- a/pyqtgraph/opengl/items/GLLinePlotItem.py +++ b/pyqtgraph/opengl/items/GLLinePlotItem.py @@ -3,6 +3,7 @@ from .. GLGraphicsItem import GLGraphicsItem from .. import shaders from ... import QtGui +from ... import functions as fn import numpy as np __all__ = ['GLLinePlotItem'] @@ -56,21 +57,6 @@ def setData(self, **kwds): def initializeGL(self): pass - #def setupGLState(self): - #"""Prepare OpenGL state for drawing. This function is called immediately before painting.""" - ##glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) ## requires z-sorting to render properly. - #glBlendFunc(GL_SRC_ALPHA, GL_ONE) - #glEnable( GL_BLEND ) - #glEnable( GL_ALPHA_TEST ) - #glDisable( GL_DEPTH_TEST ) - - ##glEnable( GL_POINT_SMOOTH ) - - ##glHint(GL_POINT_SMOOTH_HINT, GL_NICEST) - ##glPointParameterfv(GL_POINT_DISTANCE_ATTENUATION, (0, 0, -1e-3)) - ##glPointParameterfv(GL_POINT_SIZE_MAX, (65500,)) - ##glPointParameterfv(GL_POINT_SIZE_MIN, (0,)) - def paint(self): if self.pos is None: return @@ -85,12 +71,11 @@ def paint(self): glEnableClientState(GL_COLOR_ARRAY) glColorPointerf(self.color) else: - if isinstance(self.color, QtGui.QColor): + if isinstance(self.color, (str, QtGui.QColor)): glColor4f(*fn.glColor(self.color)) else: glColor4f(*self.color) glLineWidth(self.width) - #glPointSize(self.width) if self.antialias: glEnable(GL_LINE_SMOOTH) diff --git a/pyqtgraph/opengl/items/GLScatterPlotItem.py b/pyqtgraph/opengl/items/GLScatterPlotItem.py index dc4b298ae1..463ad742bc 100644 --- a/pyqtgraph/opengl/items/GLScatterPlotItem.py +++ b/pyqtgraph/opengl/items/GLScatterPlotItem.py @@ -20,6 +20,7 @@ def __init__(self, **kwds): self.pxMode = True #self.vbo = {} ## VBO does not appear to improve performance very much. self.setData(**kwds) + self.shader = None def setData(self, **kwds): """ @@ -54,11 +55,13 @@ def setData(self, **kwds): self.update() def initializeGL(self): + if self.shader is not None: + return ## Generate texture for rendering points w = 64 def fn(x,y): - r = ((x-w/2.)**2 + (y-w/2.)**2) ** 0.5 + r = ((x-(w-1)/2.)**2 + (y-(w-1)/2.)**2) ** 0.5 return 255 * (w/2. - np.clip(r, w/2.-1.0, w/2.)) pData = np.empty((w, w, 4)) pData[:] = 255 @@ -120,7 +123,7 @@ def paint(self): try: pos = self.pos #if pos.ndim > 2: - #pos = pos.reshape((reduce(lambda a,b: a*b, pos.shape[:-1]), pos.shape[-1])) + #pos = pos.reshape((-1, pos.shape[-1])) glVertexPointerf(pos) if isinstance(self.color, np.ndarray): @@ -152,7 +155,9 @@ def paint(self): glDisableClientState(GL_VERTEX_ARRAY) glDisableClientState(GL_COLOR_ARRAY) #posVBO.unbind() - + ##fixes #145 + glDisable( GL_TEXTURE_2D ) + #for i in range(len(self.pos)): #pos = self.pos[i] diff --git a/pyqtgraph/opengl/shaders.py b/pyqtgraph/opengl/shaders.py index 8922cd2143..7ada939ce1 100644 --- a/pyqtgraph/opengl/shaders.py +++ b/pyqtgraph/opengl/shaders.py @@ -140,9 +140,9 @@ def initShaders(): ## colors fragments by z-value. ## This is useful for coloring surface plots by height. ## This shader uses a uniform called "colorMap" to determine how to map the colors: - ## red = pow(z * colorMap[0] + colorMap[1], colorMap[2]) - ## green = pow(z * colorMap[3] + colorMap[4], colorMap[5]) - ## blue = pow(z * colorMap[6] + colorMap[7], colorMap[8]) + ## red = pow(colorMap[0]*(z + colorMap[1]), colorMap[2]) + ## green = pow(colorMap[3]*(z + colorMap[4]), colorMap[5]) + ## blue = pow(colorMap[6]*(z + colorMap[7]), colorMap[8]) ## (set the values like this: shader['uniformMap'] = array([...]) ShaderProgram('heightColor', [ VertexShader(""" diff --git a/pyqtgraph/ordereddict.py b/pyqtgraph/ordereddict.py index 7242b5060d..fb37037fa0 100644 --- a/pyqtgraph/ordereddict.py +++ b/pyqtgraph/ordereddict.py @@ -20,108 +20,112 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. -from UserDict import DictMixin - -class OrderedDict(dict, DictMixin): - - def __init__(self, *args, **kwds): - if len(args) > 1: - raise TypeError('expected at most 1 arguments, got %d' % len(args)) - try: - self.__end - except AttributeError: - self.clear() - self.update(*args, **kwds) - - def clear(self): - self.__end = end = [] - end += [None, end, end] # sentinel node for doubly linked list - self.__map = {} # key --> [key, prev, next] - dict.clear(self) - - def __setitem__(self, key, value): - if key not in self: +import sys +if sys.version[0] > '2': + from collections import OrderedDict +else: + from UserDict import DictMixin + + class OrderedDict(dict, DictMixin): + + def __init__(self, *args, **kwds): + if len(args) > 1: + raise TypeError('expected at most 1 arguments, got %d' % len(args)) + try: + self.__end + except AttributeError: + self.clear() + self.update(*args, **kwds) + + def clear(self): + self.__end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.__map = {} # key --> [key, prev, next] + dict.clear(self) + + def __setitem__(self, key, value): + if key not in self: + end = self.__end + curr = end[1] + curr[2] = end[1] = self.__map[key] = [key, curr, end] + dict.__setitem__(self, key, value) + + def __delitem__(self, key): + dict.__delitem__(self, key) + key, prev, next = self.__map.pop(key) + prev[2] = next + next[1] = prev + + def __iter__(self): + end = self.__end + curr = end[2] + while curr is not end: + yield curr[0] + curr = curr[2] + + def __reversed__(self): end = self.__end curr = end[1] - curr[2] = end[1] = self.__map[key] = [key, curr, end] - dict.__setitem__(self, key, value) - - def __delitem__(self, key): - dict.__delitem__(self, key) - key, prev, next = self.__map.pop(key) - prev[2] = next - next[1] = prev - - def __iter__(self): - end = self.__end - curr = end[2] - while curr is not end: - yield curr[0] - curr = curr[2] - - def __reversed__(self): - end = self.__end - curr = end[1] - while curr is not end: - yield curr[0] - curr = curr[1] - - def popitem(self, last=True): - if not self: - raise KeyError('dictionary is empty') - if last: - key = reversed(self).next() - else: - key = iter(self).next() - value = self.pop(key) - return key, value - - def __reduce__(self): - items = [[k, self[k]] for k in self] - tmp = self.__map, self.__end - del self.__map, self.__end - inst_dict = vars(self).copy() - self.__map, self.__end = tmp - if inst_dict: - return (self.__class__, (items,), inst_dict) - return self.__class__, (items,) - - def keys(self): - return list(self) - - setdefault = DictMixin.setdefault - update = DictMixin.update - pop = DictMixin.pop - values = DictMixin.values - items = DictMixin.items - iterkeys = DictMixin.iterkeys - itervalues = DictMixin.itervalues - iteritems = DictMixin.iteritems - - def __repr__(self): - if not self: - return '%s()' % (self.__class__.__name__,) - return '%s(%r)' % (self.__class__.__name__, self.items()) - - def copy(self): - return self.__class__(self) - - @classmethod - def fromkeys(cls, iterable, value=None): - d = cls() - for key in iterable: - d[key] = value - return d - - def __eq__(self, other): - if isinstance(other, OrderedDict): - if len(self) != len(other): - return False - for p, q in zip(self.items(), other.items()): - if p != q: + while curr is not end: + yield curr[0] + curr = curr[1] + + def popitem(self, last=True): + if not self: + raise KeyError('dictionary is empty') + if last: + key = reversed(self).next() + else: + key = iter(self).next() + value = self.pop(key) + return key, value + + def __reduce__(self): + items = [[k, self[k]] for k in self] + tmp = self.__map, self.__end + del self.__map, self.__end + inst_dict = vars(self).copy() + self.__map, self.__end = tmp + if inst_dict: + return (self.__class__, (items,), inst_dict) + return self.__class__, (items,) + + def keys(self): + return list(self) + + setdefault = DictMixin.setdefault + update = DictMixin.update + pop = DictMixin.pop + values = DictMixin.values + items = DictMixin.items + iterkeys = DictMixin.iterkeys + itervalues = DictMixin.itervalues + iteritems = DictMixin.iteritems + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, self.items()) + + def copy(self): + return self.__class__(self) + + @classmethod + def fromkeys(cls, iterable, value=None): + d = cls() + for key in iterable: + d[key] = value + return d + + def __eq__(self, other): + if isinstance(other, OrderedDict): + if len(self) != len(other): return False - return True - return dict.__eq__(self, other) - - def __ne__(self, other): - return not self == other + for p, q in zip(self.items(), other.items()): + if p != q: + return False + return True + return dict.__eq__(self, other) + + def __ne__(self, other): + return not self == other diff --git a/pyqtgraph/parametertree/Parameter.py b/pyqtgraph/parametertree/Parameter.py index de9a1624d8..9ef3047703 100644 --- a/pyqtgraph/parametertree/Parameter.py +++ b/pyqtgraph/parametertree/Parameter.py @@ -55,6 +55,7 @@ class Parameter(QtCore.QObject): sigDefaultChanged(self, default) Emitted when this parameter's default value has changed sigNameChanged(self, name) Emitted when this parameter's name has changed sigOptionsChanged(self, opts) Emitted when any of this parameter's options have changed + sigContextMenu(self, name) Emitted when a context menu was clicked =================================== ========================================================= """ ## name, type, limits, etc. @@ -81,7 +82,8 @@ class Parameter(QtCore.QObject): ## (but only if monitorChildren() is called) sigTreeStateChanged = QtCore.Signal(object, object) # self, changes # changes = [(param, change, info), ...] - + sigContextMenu = QtCore.Signal(object, object) # self, name + # bad planning. #def __new__(cls, *args, **opts): #try: @@ -135,9 +137,12 @@ def __init__(self, **opts): (default=False) removable If True, the user may remove this Parameter. (default=False) - expanded If True, the Parameter will appear expanded when - displayed in a ParameterTree (its children will be - visible). (default=True) + expanded If True, the Parameter will initially be expanded in + ParameterTrees: Its children will be visible. + (default=True) + syncExpanded If True, the `expanded` state of this Parameter is + synchronized with all ParameterTrees it is displayed in. + (default=False) title (str or None) If specified, then the parameter will be displayed to the user using this string as its name. However, the parameter will still be referred to @@ -159,10 +164,15 @@ def __init__(self, **opts): 'removable': False, 'strictNaming': False, # forces name to be usable as a python variable 'expanded': True, + 'syncExpanded': False, 'title': None, #'limits': None, ## This is a bad plan--each parameter type may have a different data type for limits. } + value = opts.get('value', None) + name = opts.get('name', None) self.opts.update(opts) + self.opts['value'] = None # will be set later. + self.opts['name'] = None self.childs = [] self.names = {} ## map name:child @@ -172,17 +182,19 @@ def __init__(self, **opts): self.blockTreeChangeEmit = 0 #self.monitoringChildren = False ## prevent calling monitorChildren more than once - if 'value' not in self.opts: - self.opts['value'] = None - - if 'name' not in self.opts or not isinstance(self.opts['name'], basestring): + if not isinstance(name, basestring): raise Exception("Parameter must have a string name specified in opts.") - self.setName(opts['name']) + self.setName(name) - self.addChildren(self.opts.get('children', [])) - - if 'value' in self.opts and 'default' not in self.opts: - self.opts['default'] = self.opts['value'] + self.addChildren(self.opts.pop('children', [])) + + self.opts['value'] = None + if value is not None: + self.setValue(value) + + if 'default' not in self.opts: + self.opts['default'] = None + self.setDefault(self.opts['value']) ## Connect all state changed signals to the general sigStateChanged self.sigValueChanged.connect(lambda param, data: self.emitStateChanged('value', data)) @@ -193,6 +205,8 @@ def __init__(self, **opts): self.sigDefaultChanged.connect(lambda param, data: self.emitStateChanged('default', data)) self.sigNameChanged.connect(lambda param, data: self.emitStateChanged('name', data)) self.sigOptionsChanged.connect(lambda param, data: self.emitStateChanged('options', data)) + self.sigContextMenu.connect(lambda param, data: self.emitStateChanged('contextMenu', data)) + #self.watchParam(self) ## emit treechange signals if our own state changes @@ -200,6 +214,10 @@ def name(self): """Return the name of this Parameter.""" return self.opts['name'] + def contextMenu(self, name): + """"A context menu entry was clicked""" + self.sigContextMenu.emit(self, name) + def setName(self, name): """Attempt to change the name of this parameter; return the actual name. (The parameter may reject the name change or automatically pick a different name)""" @@ -255,6 +273,7 @@ def setValue(self, value, blockSignal=None): try: if blockSignal is not None: self.sigValueChanged.disconnect(blockSignal) + value = self._interpretValue(value) if self.opts['value'] == value: return value self.opts['value'] = value @@ -265,6 +284,9 @@ def setValue(self, value, blockSignal=None): return value + def _interpretValue(self, v): + return v + def value(self): """ Return the value of this Parameter. @@ -443,7 +465,7 @@ def setOpts(self, **opts): Set any arbitrary options on this parameter. The exact behavior of this function will depend on the parameter type, but most parameters will accept a common set of options: value, name, limits, - default, readonly, removable, renamable, visible, enabled, and expanded. + default, readonly, removable, renamable, visible, enabled, expanded and syncExpanded. See :func:`Parameter.__init__ ` for more information on default options. @@ -549,8 +571,8 @@ def insertChild(self, pos, child, autoIncrementName=None): self.childs.insert(pos, child) child.parentChanged(self) - self.sigChildAdded.emit(self, child, pos) child.sigTreeStateChanged.connect(self.treeStateChanged) + self.sigChildAdded.emit(self, child, pos) return child def removeChild(self, child): @@ -561,11 +583,11 @@ def removeChild(self, child): del self.names[name] self.childs.pop(self.childs.index(child)) child.parentChanged(None) - self.sigChildRemoved.emit(self, child) try: child.sigTreeStateChanged.disconnect(self.treeStateChanged) except (TypeError, RuntimeError): ## already disconnected pass + self.sigChildRemoved.emit(self, child) def clearChildren(self): """Remove all child parameters.""" @@ -602,7 +624,7 @@ def remove(self): def incrementName(self, name): ## return an unused name by adding a number to the name given - base, num = re.match('(.*)(\d*)', name).groups() + base, num = re.match(r'(.*)(\d*)', name).groups() numLen = len(num) if numLen == 0: num = 2 @@ -643,18 +665,19 @@ def child(self, *names): """Return a child parameter. Accepts the name of the child or a tuple (path, to, child) - Added in version 0.9.9. Ealier versions used the 'param' method, which is still - implemented for backward compatibility.""" + Added in version 0.9.9. Earlier versions used the 'param' method, which is still + implemented for backward compatibility. + """ try: param = self.names[names[0]] except KeyError: - raise Exception("Parameter %s has no child named %s" % (self.name(), names[0])) + raise KeyError("Parameter %s has no child named %s" % (self.name(), names[0])) if len(names) > 1: - return param.param(*names[1:]) + return param.child(*names[1:]) else: return param - + def param(self, *names): # for backward compatibility. return self.child(*names) diff --git a/pyqtgraph/parametertree/ParameterItem.py b/pyqtgraph/parametertree/ParameterItem.py index c149c4110e..b697b95606 100644 --- a/pyqtgraph/parametertree/ParameterItem.py +++ b/pyqtgraph/parametertree/ParameterItem.py @@ -34,19 +34,20 @@ def __init__(self, param, depth=0): param.sigOptionsChanged.connect(self.optsChanged) param.sigParentChanged.connect(self.parentChanged) - opts = param.opts + self.updateFlags() + + ## flag used internally during name editing + self.ignoreNameColumnChange = False + + def updateFlags(self): + ## called when Parameter opts changed + opts = self.param.opts - ## Generate context menu for renaming/removing parameter - self.contextMenu = QtGui.QMenu() - self.contextMenu.addSeparator() flags = QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled if opts.get('renamable', False): - if param.opts.get('title', None) is not None: + if opts.get('title', None) is not None: raise Exception("Cannot make parameter with both title != None and renamable == True.") flags |= QtCore.Qt.ItemIsEditable - self.contextMenu.addAction('Rename').triggered.connect(self.editName) - if opts.get('removable', False): - self.contextMenu.addAction("Remove").triggered.connect(self.requestRemove) ## handle movable / dropEnabled options if opts.get('movable', False): @@ -54,10 +55,7 @@ def __init__(self, param, depth=0): if opts.get('dropEnabled', False): flags |= QtCore.Qt.ItemIsDropEnabled self.setFlags(flags) - - ## flag used internally during name editing - self.ignoreNameColumnChange = False - + def valueChanged(self, param, val): ## called when the parameter's value has changed @@ -106,9 +104,31 @@ def parentChanged(self, param, parent): pass def contextMenuEvent(self, ev): - if not self.param.opts.get('removable', False) and not self.param.opts.get('renamable', False): + opts = self.param.opts + + if not opts.get('removable', False) and not opts.get('renamable', False)\ + and "context" not in opts: return - + + ## Generate context menu for renaming/removing parameter + self.contextMenu = QtGui.QMenu() # Put in global name space to prevent garbage collection + self.contextMenu.addSeparator() + if opts.get('renamable', False): + self.contextMenu.addAction('Rename').triggered.connect(self.editName) + if opts.get('removable', False): + self.contextMenu.addAction("Remove").triggered.connect(self.requestRemove) + + # context menu + context = opts.get('context', None) + if isinstance(context, list): + for name in context: + self.contextMenu.addAction(name).triggered.connect( + self.contextMenuTriggered(name)) + elif isinstance(context, dict): + for name, title in context.items(): + self.contextMenu.addAction(title).triggered.connect( + self.contextMenuTriggered(name)) + self.contextMenu.popup(ev.globalPos()) def columnChangedEvent(self, col): @@ -129,6 +149,10 @@ def columnChangedEvent(self, col): self.nameChanged(self, newName) ## If the parameter rejects the name change, we need to set it back. finally: self.ignoreNameColumnChange = False + + def expandedChangedEvent(self, expanded): + if self.param.opts['syncExpanded']: + self.param.setOpts(expanded=expanded) def nameChanged(self, param, name): ## called when the parameter's name has changed. @@ -146,10 +170,27 @@ def defaultChanged(self, param, default): def optsChanged(self, param, opts): """Called when any options are changed that are not name, value, default, or limits""" - #print opts if 'visible' in opts: self.setHidden(not opts['visible']) + + if 'expanded' in opts: + if self.param.opts['syncExpanded']: + if self.isExpanded() != opts['expanded']: + self.setExpanded(opts['expanded']) + if 'syncExpanded' in opts: + if opts['syncExpanded']: + if self.isExpanded() != self.param.opts['expanded']: + self.setExpanded(self.param.opts['expanded']) + + self.updateFlags() + + + def contextMenuTriggered(self, name): + def trigger(): + self.param.contextMenu(name) + return trigger + def editName(self): self.treeWidget().editItem(self, 0) diff --git a/pyqtgraph/parametertree/ParameterTree.py b/pyqtgraph/parametertree/ParameterTree.py index ef7c1030b7..de6ab12601 100644 --- a/pyqtgraph/parametertree/ParameterTree.py +++ b/pyqtgraph/parametertree/ParameterTree.py @@ -28,6 +28,8 @@ def __init__(self, parent=None, showHeader=True): self.header().setResizeMode(QtGui.QHeaderView.ResizeToContents) self.setHeaderHidden(not showHeader) self.itemChanged.connect(self.itemChangedEvent) + self.itemExpanded.connect(self.itemExpandedEvent) + self.itemCollapsed.connect(self.itemCollapsedEvent) self.lastSel = None self.setRootIsDecorated(False) @@ -134,6 +136,14 @@ def contextMenuEvent(self, ev): def itemChangedEvent(self, item, col): if hasattr(item, 'columnChangedEvent'): item.columnChangedEvent(col) + + def itemExpandedEvent(self, item): + if hasattr(item, 'expandedChangedEvent'): + item.expandedChangedEvent(True) + + def itemCollapsedEvent(self, item): + if hasattr(item, 'expandedChangedEvent'): + item.expandedChangedEvent(False) def selectionChanged(self, *args): sel = self.selectedItems() diff --git a/pyqtgraph/parametertree/SystemSolver.py b/pyqtgraph/parametertree/SystemSolver.py index 24e35e9a63..b1d4256a1d 100644 --- a/pyqtgraph/parametertree/SystemSolver.py +++ b/pyqtgraph/parametertree/SystemSolver.py @@ -1,5 +1,7 @@ from ..pgcollections import OrderedDict import numpy as np +import copy + class SystemSolver(object): """ @@ -73,6 +75,12 @@ def __init__(self): self.__dict__['_currentGets'] = set() self.reset() + def copy(self): + sys = type(self)() + sys.__dict__['_vars'] = copy.deepcopy(self.__dict__['_vars']) + sys.__dict__['_currentGets'] = copy.deepcopy(self.__dict__['_currentGets']) + return sys + def reset(self): """ Reset all variables in the solver to their default state. @@ -167,6 +175,16 @@ def set(self, name, value=None, constraint=True): elif constraint == 'fixed': if 'f' not in var[3]: raise TypeError("Fixed constraints not allowed for '%s'" % name) + # This is nice, but not reliable because sometimes there is 1 DOF but we set 2 + # values simultaneously. + # if var[2] is None: + # try: + # self.get(name) + # # has already been computed by the system; adding a fixed constraint + # # would overspecify the system. + # raise ValueError("Cannot fix parameter '%s'; system would become overconstrained." % name) + # except RuntimeError: + # pass var[2] = constraint elif isinstance(constraint, tuple): if 'r' not in var[3]: @@ -177,7 +195,7 @@ def set(self, name, value=None, constraint=True): raise TypeError("constraint must be None, True, 'fixed', or tuple. (got %s)" % constraint) # type checking / massaging - if var[1] is np.ndarray: + if var[1] is np.ndarray and value is not None: value = np.array(value, dtype=float) elif var[1] in (int, float, tuple) and value is not None: value = var[1](value) @@ -185,9 +203,9 @@ def set(self, name, value=None, constraint=True): # constraint checks if constraint is True and not self.check_constraint(name, value): raise ValueError("Setting %s = %s violates constraint %s" % (name, value, var[2])) - + # invalidate other dependent values - if var[0] is not None: + if var[0] is not None or value is None: # todo: we can make this more clever..(and might need to) # we just know that a value of None cannot have dependencies # (because if anyone else had asked for this value, it wouldn't be @@ -237,6 +255,31 @@ def solve(self): for k in self._vars: getattr(self, k) + def checkOverconstraint(self): + """Check whether the system is overconstrained. If so, return the name of + the first overconstrained parameter. + + Overconstraints occur when any fixed parameter can be successfully computed by the system. + (Ideally, all parameters are either fixed by the user or constrained by the + system, but never both). + """ + for k,v in self._vars.items(): + if v[2] == 'fixed' and 'n' in v[3]: + oldval = v[:] + self.set(k, None, None) + try: + self.get(k) + return k + except RuntimeError: + pass + finally: + self._vars[k] = oldval + + return False + + + + def __repr__(self): state = OrderedDict() for name, var in self._vars.items(): @@ -378,4 +421,4 @@ def _balance(self): camera.solve() print(camera.saveState()) - \ No newline at end of file + diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py index 3171748103..f1c05179b2 100644 --- a/pyqtgraph/parametertree/parameterTypes.py +++ b/pyqtgraph/parametertree/parameterTypes.py @@ -4,10 +4,11 @@ from .ParameterItem import ParameterItem from ..widgets.SpinBox import SpinBox from ..widgets.ColorButton import ColorButton +from ..colormap import ColorMap #from ..widgets.GradientWidget import GradientWidget ## creates import loop from .. import pixmaps as pixmaps from .. import functions as fn -import os +import os, sys from ..pgcollections import OrderedDict class WidgetParameterItem(ParameterItem): @@ -43,10 +44,6 @@ def __init__(self, param, depth): self.widget = w self.eventProxy = EventProxy(w, self.widgetEventFilter) - opts = self.param.opts - if 'tip' in opts: - w.setToolTip(opts['tip']) - self.defaultBtn = QtGui.QPushButton() self.defaultBtn.setFixedWidth(20) self.defaultBtn.setFixedHeight(20) @@ -72,6 +69,7 @@ def __init__(self, param, depth): w.sigChanging.connect(self.widgetValueChanging) ## update value shown in widget. + opts = self.param.opts if opts.get('value', None) is not None: self.valueChanged(self, opts['value'], force=True) else: @@ -79,6 +77,8 @@ def __init__(self, param, depth): self.widgetValueChanged() self.updateDefaultBtn() + + self.optsChanged(self.param, self.param.opts) def makeWidget(self): """ @@ -104,11 +104,12 @@ def makeWidget(self): if t == 'int': defs['int'] = True defs['minStep'] = 1.0 + defs['format'] = '{value:d}' for k in defs: if k in opts: defs[k] = opts[k] if 'limits' in opts: - defs['bounds'] = opts['limits'] + defs['min'], defs['max'] = opts['limits'] w = SpinBox() w.setOpts(**defs) w.sigChanged = w.sigValueChanged @@ -122,6 +123,7 @@ def makeWidget(self): self.hideWidget = False elif t == 'str': w = QtGui.QLineEdit() + w.setStyleSheet('border: 0px') w.sigChanged = w.editingFinished w.value = lambda: asUnicode(w.text()) w.setValue = lambda v: w.setText(asUnicode(v)) @@ -277,11 +279,19 @@ def optsChanged(self, param, opts): if isinstance(self.widget, (QtGui.QCheckBox,ColorButton)): self.widget.setEnabled(not opts['readonly']) + if 'tip' in opts: + self.widget.setToolTip(opts['tip']) + ## If widget is a SpinBox, pass options straight through if isinstance(self.widget, SpinBox): + # send only options supported by spinbox + sbOpts = {} if 'units' in opts and 'suffix' not in opts: - opts['suffix'] = opts['units'] - self.widget.setOpts(**opts) + sbOpts['suffix'] = opts['units'] + for k,v in opts.items(): + if k in self.widget.opts: + sbOpts[k] = v + self.widget.setOpts(**sbOpts) self.updateDisplayLabel() @@ -314,6 +324,26 @@ def saveColorState(self, *args, **kwds): state['value'] = fn.colorTuple(self.value()) return state + def _interpretValue(self, v): + fn = { + 'int': int, + 'float': float, + 'bool': bool, + 'str': asUnicode, + 'color': self._interpColor, + 'colormap': self._interpColormap, + }[self.opts['type']] + return fn(v) + + def _interpColor(self, v): + return fn.mkColor(v) + + def _interpColormap(self, v): + if not isinstance(v, ColorMap): + raise TypeError("Cannot set colormap parameter from object %r" % v) + return v + + registerParameterType('int', SimpleParameter, override=True) registerParameterType('float', SimpleParameter, override=True) @@ -373,6 +403,7 @@ def updateDepth(self, depth): else: for c in [0,1]: self.setBackground(c, QtGui.QBrush(QtGui.QColor(220,220,220))) + self.setForeground(c, QtGui.QBrush(QtGui.QColor(50,50,50))) font = self.font(c) font.setBold(True) #font.setPointSize(font.pointSize()+1) @@ -397,10 +428,13 @@ def addChanged(self): def treeWidgetChanged(self): ParameterItem.treeWidgetChanged(self) - self.treeWidget().setFirstItemColumnSpanned(self, True) + tw = self.treeWidget() + if tw is None: + return + tw.setFirstItemColumnSpanned(self, True) if self.addItem is not None: - self.treeWidget().setItemWidget(self.addItem, 0, self.addWidgetBox) - self.treeWidget().setFirstItemColumnSpanned(self.addItem, True) + tw.setItemWidget(self.addItem, 0, self.addWidgetBox) + tw.setFirstItemColumnSpanned(self.addItem, True) def addChild(self, child): ## make sure added childs are actually inserted before add btn if self.addItem is not None: @@ -408,8 +442,10 @@ def addChild(self, child): ## make sure added childs are actually inserted befo else: ParameterItem.addChild(self, child) - def optsChanged(self, param, changed): - if 'addList' in changed: + def optsChanged(self, param, opts): + ParameterItem.optsChanged(self, param, opts) + + if 'addList' in opts: self.updateAddList() def updateAddList(self): @@ -435,12 +471,15 @@ class GroupParameter(Parameter): instead of a button. """ itemClass = GroupParameterItem + + sigAddNew = QtCore.Signal(object, object) # self, type def addNew(self, typ=None): """ This method is called when the user has requested to add a new item to the group. + By default, it emits ``sigAddNew(self, typ)``. """ - raise Exception("Must override this function in subclass.") + self.sigAddNew.emit(self, typ) def setAddList(self, vals): """Change the list of options available for the user to add to the group.""" @@ -578,8 +617,12 @@ def __init__(self, param, depth): ParameterItem.__init__(self, param, depth) self.layoutWidget = QtGui.QWidget() self.layout = QtGui.QHBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) self.layoutWidget.setLayout(self.layout) - self.button = QtGui.QPushButton(param.name()) + title = param.opts.get('title', None) + if title is None: + title = param.name() + self.button = QtGui.QPushButton(title) #self.layout.addSpacing(100) self.layout.addWidget(self.button) self.layout.addStretch() @@ -626,8 +669,12 @@ def treeWidgetChanged(self): ## TODO: fix so that superclass method can be called ## (WidgetParameter should just natively support this style) #WidgetParameterItem.treeWidgetChanged(self) - self.treeWidget().setFirstItemColumnSpanned(self.subItem, True) - self.treeWidget().setItemWidget(self.subItem, 0, self.textBox) + tw = self.treeWidget() + if tw is None: + return + + tw.setFirstItemColumnSpanned(self.subItem, True) + tw.setItemWidget(self.subItem, 0, self.textBox) # for now, these are copied from ParameterItem.treeWidgetChanged self.setHidden(not self.param.opts.get('visible', True)) diff --git a/pyqtgraph/parametertree/tests/test_parametertypes.py b/pyqtgraph/parametertree/tests/test_parametertypes.py index dc581019b1..a654a9ad84 100644 --- a/pyqtgraph/parametertree/tests/test_parametertypes.py +++ b/pyqtgraph/parametertree/tests/test_parametertypes.py @@ -1,7 +1,19 @@ +# ~*~ coding: utf8 ~*~ +import sys +import pytest +from pyqtgraph.Qt import QtGui, QtCore import pyqtgraph.parametertree as pt import pyqtgraph as pg +from pyqtgraph.python2_3 import asUnicode +from pyqtgraph.functions import eq +import numpy as np + app = pg.mkQApp() +def _getWidget(param): + return list(param.items.keys())[0].widget + + def test_opts(): paramSpec = [ dict(name='bool', type='bool', readonly=True), @@ -12,7 +24,111 @@ def test_opts(): tree = pt.ParameterTree() tree.setParameters(param) - assert list(param.param('bool').items.keys())[0].widget.isEnabled() is False - assert list(param.param('color').items.keys())[0].widget.isEnabled() is False + assert _getWidget(param.param('bool')).isEnabled() is False + assert _getWidget(param.param('bool')).isEnabled() is False + + +def test_types(): + paramSpec = [ + dict(name='float', type='float'), + dict(name='int', type='int'), + dict(name='str', type='str'), + dict(name='list', type='list', values=['x','y','z']), + dict(name='dict', type='list', values={'x':1, 'y':3, 'z':7}), + dict(name='bool', type='bool'), + dict(name='color', type='color'), + ] + + param = pt.Parameter.create(name='params', type='group', children=paramSpec) + tree = pt.ParameterTree() + tree.setParameters(param) + + all_objs = { + 'int0': 0, 'int':7, 'float': -0.35, 'bigfloat': 1e129, 'npfloat': np.float(5), + 'npint': np.int(5),'npinf': np.inf, 'npnan': np.nan, 'bool': True, + 'complex': 5+3j, 'str': 'xxx', 'unicode': asUnicode('µ'), + 'list': [1,2,3], 'dict': {'1': 2}, 'color': pg.mkColor('k'), + 'brush': pg.mkBrush('k'), 'pen': pg.mkPen('k'), 'none': None + } + if hasattr(QtCore, 'QString'): + all_objs['qstring'] = QtCore.QString('xxxµ') + + # float + types = ['int0', 'int', 'float', 'bigfloat', 'npfloat', 'npint', 'npinf', 'npnan', 'bool'] + check_param_types(param.child('float'), float, float, 0.0, all_objs, types) + + # int + types = ['int0', 'int', 'float', 'bigfloat', 'npfloat', 'npint', 'bool'] + inttyps = int if sys.version[0] >= '3' else (int, long) + check_param_types(param.child('int'), inttyps, int, 0, all_objs, types) + + # str (should be able to make a string out of any type) + types = all_objs.keys() + strtyp = str if sys.version[0] >= '3' else unicode + check_param_types(param.child('str'), strtyp, asUnicode, '', all_objs, types) + + # bool (should be able to make a boolean out of any type?) + types = all_objs.keys() + check_param_types(param.child('bool'), bool, bool, False, all_objs, types) + # color + types = ['color', 'int0', 'int', 'float', 'npfloat', 'npint', 'list'] + init = QtGui.QColor(128, 128, 128, 255) + check_param_types(param.child('color'), QtGui.QColor, pg.mkColor, init, all_objs, types) + +def check_param_types(param, types, map_func, init, objs, keys): + """Check that parameter setValue() accepts or rejects the correct types and + that value() returns the correct type. + + Parameters + ---------- + param : Parameter instance + types : type or tuple of types + The allowed types for this parameter to return from value(). + map_func : function + Converts an input value to the expected output value. + init : object + The expected initial value of the parameter + objs : dict + Contains a variety of objects that will be tested as arguments to + param.setValue(). + keys : list + The list of keys indicating the valid objects in *objs*. When + param.setValue() is teasted with each value from *objs*, we expect + an exception to be raised if the associated key is not in *keys*. + """ + val = param.value() + if not isinstance(types, tuple): + types = (types,) + assert val == init and type(val) in types + + # test valid input types + good_inputs = [objs[k] for k in keys if k in objs] + good_outputs = map(map_func, good_inputs) + for x,y in zip(good_inputs, good_outputs): + param.setValue(x) + val = param.value() + if not (eq(val, y) and type(val) in types): + raise Exception("Setting parameter %s with value %r should have resulted in %r (types: %r), " + "but resulted in %r (type: %r) instead." % (param, x, y, types, val, type(val))) + + # test invalid input types + for k,v in objs.items(): + if k in keys: + continue + try: + param.setValue(v) + except (TypeError, ValueError, OverflowError): + continue + except Exception as exc: + raise Exception("Setting %s parameter value to %r raised %r." % (param, v, exc)) + + raise Exception("Setting %s parameter value to %r should have raised an exception." % (param, v)) + + + + + + + \ No newline at end of file diff --git a/pyqtgraph/pgcollections.py b/pyqtgraph/pgcollections.py index b1fc779f56..49ed4ed643 100644 --- a/pyqtgraph/pgcollections.py +++ b/pyqtgraph/pgcollections.py @@ -10,15 +10,22 @@ - ThreadsafeDict, ThreadsafeList - Self-mutexed data structures """ -import threading, sys, copy, collections -#from debug import * +import threading +import sys +import copy try: from collections import OrderedDict except ImportError: # fallback: try to use the ordereddict backport when using python 2.6 from ordereddict import OrderedDict - + +try: + from collections.abc import Sequence +except ImportError: + # fallback for python < 3.3 + from collections import Sequence + class ReverseDict(dict): """extends dict so that reverse lookups are possible by requesting the key as a list of length 1: @@ -239,7 +246,7 @@ def __contains__(self, key): return key.lower() in self.keyMap def update(self, d): - for k, v in d.iteritems(): + for k, v in d.items(): self[k] = v def copy(self): @@ -311,11 +318,11 @@ def copy(self): raise Exception("It is not safe to copy protected dicts! (instead try deepcopy, but be careful.)") def itervalues(self): - for v in self._data_.itervalues(): + for v in self._data_.values(): yield protect(v) def iteritems(self): - for k, v in self._data_.iteritems(): + for k, v in self._data_.items(): yield (k, protect(v)) def deepcopy(self): @@ -326,7 +333,7 @@ def __deepcopy__(self, memo): -class ProtectedList(collections.Sequence): +class ProtectedList(Sequence): """ A class allowing read-only 'view' of a list or dict. The object can be treated like a normal list, but will never modify the original list it points to. @@ -408,7 +415,7 @@ def poop(self): raise Exception("This is a list. It does not poop.") -class ProtectedTuple(collections.Sequence): +class ProtectedTuple(Sequence): """ A class allowing read-only 'view' of a tuple. The object can be treated like a normal tuple, but its contents will be returned as protected objects. diff --git a/pyqtgraph/pixmaps/compile.py b/pyqtgraph/pixmaps/compile.py index fa0d240853..68fd2da14d 100644 --- a/pyqtgraph/pixmaps/compile.py +++ b/pyqtgraph/pixmaps/compile.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import numpy as np from PyQt4 import QtGui import os, pickle, sys @@ -14,6 +15,5 @@ arr = np.asarray(ptr).reshape(img.height(), img.width(), 4).transpose(1,0,2) pixmaps[f] = pickle.dumps(arr) ver = sys.version_info[0] -fh = open(os.path.join(path, 'pixmapData_%d.py' %ver), 'w') -fh.write("import numpy as np; pixmapData=%s" % repr(pixmaps)) - +with open(os.path.join(path, 'pixmapData_%d.py' % (ver, )), 'w') as fh: + fh.write("import numpy as np; pixmapData=%s" % (repr(pixmaps), )) diff --git a/pyqtgraph/ptime.py b/pyqtgraph/ptime.py index 4e761afec7..eb0129340e 100644 --- a/pyqtgraph/ptime.py +++ b/pyqtgraph/ptime.py @@ -7,22 +7,29 @@ import sys -import time as systime + +if sys.version_info[0] < 3: + from time import clock + from time import time as system_time +else: + from time import perf_counter as clock + from time import time as system_time + START_TIME = None time = None def winTime(): """Return the current time in seconds with high precision (windows version, use Manager.time() to stay platform independent).""" - return systime.clock() + START_TIME + return clock() - START_TIME #return systime.time() def unixTime(): """Return the current time in seconds with high precision (unix version, use Manager.time() to stay platform independent).""" - return systime.time() + return system_time() if sys.platform.startswith('win'): - cstart = systime.clock() ### Required to start the clock in windows - START_TIME = systime.time() - cstart + cstart = clock() ### Required to start the clock in windows + START_TIME = system_time() - cstart time = winTime else: diff --git a/pyqtgraph/python2_3.py b/pyqtgraph/python2_3.py index ae4667ebd4..952b49b107 100644 --- a/pyqtgraph/python2_3.py +++ b/pyqtgraph/python2_3.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Helper functions that smooth out the differences between python 2 and 3. """ @@ -13,46 +14,12 @@ def asUnicode(x): return unicode(x) else: return str(x) - -def cmpToKey(mycmp): - 'Convert a cmp= function into a key= function' - class K(object): - def __init__(self, obj, *args): - self.obj = obj - def __lt__(self, other): - return mycmp(self.obj, other.obj) < 0 - def __gt__(self, other): - return mycmp(self.obj, other.obj) > 0 - def __eq__(self, other): - return mycmp(self.obj, other.obj) == 0 - def __le__(self, other): - return mycmp(self.obj, other.obj) <= 0 - def __ge__(self, other): - return mycmp(self.obj, other.obj) >= 0 - def __ne__(self, other): - return mycmp(self.obj, other.obj) != 0 - return K -def sortList(l, cmpFunc): - if sys.version_info[0] == 2: - l.sort(cmpFunc) - else: - l.sort(key=cmpToKey(cmpFunc)) if sys.version_info[0] == 3: basestring = str - def cmp(a,b): - if a>b: - return 1 - elif b > a: - return -1 - else: - return 0 xrange = range else: import __builtin__ basestring = __builtin__.basestring - cmp = __builtin__.cmp xrange = __builtin__.xrange - - \ No newline at end of file diff --git a/pyqtgraph/reload.py b/pyqtgraph/reload.py index ccf83913b8..b0c875f18d 100644 --- a/pyqtgraph/reload.py +++ b/pyqtgraph/reload.py @@ -21,13 +21,17 @@ print module.someObject """ - -import inspect, os, sys, gc, traceback +from __future__ import print_function +import inspect, os, sys, gc, traceback, types +from .debug import printExc try: - import __builtin__ as builtins + from importlib import reload as orig_reload except ImportError: - import builtins -from .debug import printExc + orig_reload = reload + + +py3 = sys.version_info >= (3,) + def reloadAll(prefix=None, debug=False): """Automatically reload everything whose __file__ begins with prefix. @@ -43,7 +47,7 @@ def reloadAll(prefix=None, debug=False): continue ## Ignore if the file name does not start with prefix - if not hasattr(mod, '__file__') or os.path.splitext(mod.__file__)[1] not in ['.py', '.pyc']: + if not hasattr(mod, '__file__') or mod.__file__ is None or os.path.splitext(mod.__file__)[1] not in ['.py', '.pyc']: continue if prefix is not None and mod.__file__[:len(prefix)] != prefix: continue @@ -79,7 +83,7 @@ def reload(module, debug=False, lists=False, dicts=False): ## make a copy of the old module dictionary, reload, then grab the new module dictionary for comparison oldDict = module.__dict__.copy() - builtins.reload(module) + orig_reload(module) newDict = module.__dict__ ## Allow modules access to the old dictionary after they reload @@ -97,7 +101,9 @@ def reload(module, debug=False, lists=False, dicts=False): if debug: print(" Updating class %s.%s (0x%x -> 0x%x)" % (module.__name__, k, id(old), id(new))) updateClass(old, new, debug) - + # don't put this inside updateClass because it is reentrant. + new.__previous_reload_version__ = old + elif inspect.isfunction(old): depth = updateFunction(old, new, debug) if debug: @@ -127,6 +133,9 @@ def updateFunction(old, new, debug, depth=0, visited=None): old.__code__ = new.__code__ old.__defaults__ = new.__defaults__ + if hasattr(old, '__kwdefaults'): + old.__kwdefaults__ = new.__kwdefaults__ + old.__doc__ = new.__doc__ if visited is None: visited = [] @@ -151,8 +160,9 @@ def updateFunction(old, new, debug, depth=0, visited=None): ## For classes: ## 1) find all instances of the old class and set instance.__class__ to the new class ## 2) update all old class methods to use code from the new class methods -def updateClass(old, new, debug): + +def updateClass(old, new, debug): ## Track town all instances and subclasses of old refs = gc.get_referrers(old) for ref in refs: @@ -174,13 +184,20 @@ def updateClass(old, new, debug): ## This seems to work. Is there any reason not to? ## Note that every time we reload, the class hierarchy becomes more complex. ## (and I presume this may slow things down?) - ref.__bases__ = ref.__bases__[:ind] + (new,old) + ref.__bases__[ind+1:] + newBases = ref.__bases__[:ind] + (new,old) + ref.__bases__[ind+1:] + try: + ref.__bases__ = newBases + except TypeError: + print(" Error setting bases for class %s" % ref) + print(" old bases: %s" % repr(ref.__bases__)) + print(" new bases: %s" % repr(newBases)) + raise if debug: print(" Changed superclass for %s" % safeStr(ref)) #else: #if debug: #print " Ignoring reference", type(ref) - except: + except Exception: print("Error updating reference (%s) for class change (%s -> %s)" % (safeStr(ref), safeStr(old), safeStr(new))) raise @@ -189,7 +206,8 @@ def updateClass(old, new, debug): ## but it fixes a few specific cases (pyqt signals, for one) for attr in dir(old): oa = getattr(old, attr) - if inspect.ismethod(oa): + if (py3 and inspect.isfunction(oa)) or inspect.ismethod(oa): + # note python2 has unbound methods, whereas python3 just uses plain functions try: na = getattr(new, attr) except AttributeError: @@ -197,9 +215,14 @@ def updateClass(old, new, debug): print(" Skipping method update for %s; new class does not have this attribute" % attr) continue - if hasattr(oa, 'im_func') and hasattr(na, 'im_func') and oa.__func__ is not na.__func__: - depth = updateFunction(oa.__func__, na.__func__, debug) - #oa.im_class = new ## bind old method to new class ## not allowed + ofunc = getattr(oa, '__func__', oa) # in py2 we have to get the __func__ from unbound method, + nfunc = getattr(na, '__func__', na) # in py3 the attribute IS the function + + if ofunc is not nfunc: + depth = updateFunction(ofunc, nfunc, debug) + if not hasattr(nfunc, '__previous_reload_method__'): + nfunc.__previous_reload_method__ = oa # important for managing signal connection + #oa.__class__ = new ## bind old method to new class ## not allowed if debug: extra = "" if depth > 0: @@ -208,6 +231,8 @@ def updateClass(old, new, debug): ## And copy in new functions that didn't exist previously for attr in dir(new): + if attr == '__previous_reload_version__': + continue if not hasattr(old, attr): if debug: print(" Adding missing attribute %s" % attr) @@ -223,14 +248,37 @@ def updateClass(old, new, debug): def safeStr(obj): try: s = str(obj) - except: + except Exception: try: s = repr(obj) - except: + except Exception: s = "" % (safeStr(type(obj)), id(obj)) return s +def getPreviousVersion(obj): + """Return the previous version of *obj*, or None if this object has not + been reloaded. + """ + if isinstance(obj, type) or inspect.isfunction(obj): + return getattr(obj, '__previous_reload_version__', None) + elif inspect.ismethod(obj): + if obj.__self__ is None: + # unbound method + return getattr(obj.__func__, '__previous_reload_method__', None) + else: + oldmethod = getattr(obj.__func__, '__previous_reload_method__', None) + if oldmethod is None: + return None + self = obj.__self__ + oldfunc = getattr(oldmethod, '__func__', oldmethod) + if hasattr(oldmethod, 'im_class'): + # python 2 + cls = oldmethod.im_class + return types.MethodType(oldfunc, self, cls) + else: + # python 3 + return types.MethodType(oldfunc, self) @@ -258,7 +306,8 @@ def emit(self): import os if not os.path.isdir('test1'): os.mkdir('test1') - open('test1/__init__.py', 'w') + with open('test1/__init__.py', 'w'): + pass modFile1 = "test1/test1.py" modCode1 = """ import sys @@ -297,8 +346,10 @@ def fn(): print("fn: %s") """ - open(modFile1, 'w').write(modCode1%(1,1)) - open(modFile2, 'w').write(modCode2%"message 1") + with open(modFile1, 'w') as f: + f.write(modCode1 % (1, 1)) + with open(modFile2, 'w') as f: + f.write(modCode2 % ("message 1", )) import test1.test1 as test1 import test2 print("Test 1 originals:") @@ -334,7 +385,8 @@ def fn(): c1.fn() os.remove(modFile1+'c') - open(modFile1, 'w').write(modCode1%(2,2)) + with open(modFile1, 'w') as f: + f.write(modCode1 %(2, 2)) print("\n----RELOAD test1-----\n") reloadAll(os.path.abspath(__file__)[:10], debug=True) @@ -345,7 +397,8 @@ def fn(): os.remove(modFile2+'c') - open(modFile2, 'w').write(modCode2%"message 2") + with open(modFile2, 'w') as f: + f.write(modCode2 % ("message 2", )) print("\n----RELOAD test2-----\n") reloadAll(os.path.abspath(__file__)[:10], debug=True) @@ -381,8 +434,10 @@ def fn(): os.remove(modFile1+'c') os.remove(modFile2+'c') - open(modFile1, 'w').write(modCode1%(3,3)) - open(modFile2, 'w').write(modCode2%"message 3") + with open(modFile1, 'w') as f: + f.write(modCode1 % (3, 3)) + with open(modFile2, 'w') as f: + f.write(modCode2 % ("message 3", )) print("\n----RELOAD-----\n") reloadAll(os.path.abspath(__file__)[:10], debug=True) diff --git a/pyqtgraph/tests/__init__.py b/pyqtgraph/tests/__init__.py index a4fc235af4..393bd3c5c0 100644 --- a/pyqtgraph/tests/__init__.py +++ b/pyqtgraph/tests/__init__.py @@ -1,2 +1,2 @@ from .image_testing import assertImageApproved, TransposedImageItem -from .ui_testing import mousePress, mouseMove, mouseRelease, mouseDrag, mouseClick +from .ui_testing import resizeWindow, mousePress, mouseMove, mouseRelease, mouseDrag, mouseClick diff --git a/pyqtgraph/tests/image_testing.py b/pyqtgraph/tests/image_testing.py index c8a41dec99..cfb62bb957 100644 --- a/pyqtgraph/tests/image_testing.py +++ b/pyqtgraph/tests/image_testing.py @@ -10,11 +10,13 @@ $ PYQTGRAPH_AUDIT=1 python pyqtgraph/graphicsItems/tests/test_PlotCurveItem.py - Any failing tests will - display the test results, standard image, and the differences between the - two. If the test result is bad, then press (f)ail. If the test result is - good, then press (p)ass and the new image will be saved to the test-data - directory. + Any failing tests will display the test results, standard image, and the + differences between the two. If the test result is bad, then press (f)ail. + If the test result is good, then press (p)ass and the new image will be + saved to the test-data directory. + + To check all test results regardless of whether the test failed, set the + environment variable PYQTGRAPH_AUDIT_ALL=1. 3. After adding or changing test images, create a new commit: @@ -42,7 +44,7 @@ # pyqtgraph should be tested against. When adding or changing test images, # create and push a new tag and update this variable. To test locally, begin # by creating the tag in your ~/.pyqtgraph/test-data repository. -testDataTag = 'test-data-6' +testDataTag = 'test-data-7' import time @@ -162,6 +164,8 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): # If the test image does not match, then we go to audit if requested. try: + if stdImage is None: + raise Exception("No reference image saved for this test.") if image.shape[2] != stdImage.shape[2]: raise Exception("Test result has different channel count than standard image" "(%d vs %d)" % (image.shape[2], stdImage.shape[2])) @@ -187,6 +191,7 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): if os.getenv('PYQTGRAPH_AUDIT_ALL') == '1': raise Exception("Image test passed, but auditing due to PYQTGRAPH_AUDIT_ALL evnironment variable.") except Exception: + if stdFileName in gitStatus(dataPath): print("\n\nWARNING: unit test failed against modified standard " "image %s.\nTo revert this file, run `cd %s; git checkout " @@ -206,6 +211,9 @@ def assertImageApproved(image, standardFile, message=None, **kwargs): "PYQTGRAPH_AUDIT=1 to add this image." % stdFileName) else: if os.getenv('TRAVIS') is not None: + saveFailedTest(image, stdImage, standardFile, upload=True) + elif os.getenv('AZURE') is not None: + standardFile = os.path.join(os.getenv("SCREENSHOT_DIR", "screenshots"), standardFile) saveFailedTest(image, stdImage, standardFile) print(graphstate) raise @@ -249,7 +257,7 @@ def assertImageMatch(im1, im2, minCorr=None, pxThreshold=50., assert im1.dtype == im2.dtype if pxCount == -1: - if QT_LIB == 'PyQt5': + if QT_LIB in {'PyQt5', 'PySide2'}: # Qt5 generates slightly different results; relax the tolerance # until test images are updated. pxCount = int(im1.shape[0] * im1.shape[1] * 0.01) @@ -277,15 +285,9 @@ def assertImageMatch(im1, im2, minCorr=None, pxThreshold=50., assert corr >= minCorr -def saveFailedTest(data, expect, filename): +def saveFailedTest(data, expect, filename, upload=False): """Upload failed test images to web server to allow CI test debugging. """ - commit = runSubprocess(['git', 'rev-parse', 'HEAD']) - name = filename.split('/') - name.insert(-1, commit.strip()) - filename = '/'.join(name) - host = 'data.pyqtgraph.org' - # concatenate data, expect, and diff into a single image ds = data.shape es = expect.shape @@ -302,15 +304,31 @@ def saveFailedTest(data, expect, filename): img[2:2+diff.shape[0], -diff.shape[1]-2:-2] = diff png = makePng(img) - + directory = os.path.dirname(filename) + if not os.path.isdir(directory): + os.makedirs(directory) + with open(filename + ".png", "wb") as png_file: + png_file.write(png) + print("\nImage comparison failed. Test result: %s %s Expected result: " + "%s %s" % (data.shape, data.dtype, expect.shape, expect.dtype)) + if upload: + uploadFailedTest(filename, png) + + +def uploadFailedTest(filename, png): + commit = runSubprocess(['git', 'rev-parse', 'HEAD']) + name = filename.split(os.path.sep) + name.insert(-1, commit.strip()) + filename = os.path.sep.join(name) + + host = 'data.pyqtgraph.org' conn = httplib.HTTPConnection(host) req = urllib.urlencode({'name': filename, 'data': base64.b64encode(png)}) conn.request('POST', '/upload.py', req) response = conn.getresponse().read() conn.close() - print("\nImage comparison failed. Test result: %s %s Expected result: " - "%s %s" % (data.shape, data.dtype, expect.shape, expect.dtype)) + print("Uploaded to: \nhttp://%s/data/%s" % (host, filename)) if not response.startswith(b'OK'): print("WARNING: Error uploading data to %s" % host) @@ -491,7 +509,7 @@ def getTestDataRepo(): if not os.path.isdir(parentPath): os.makedirs(parentPath) - if os.getenv('TRAVIS') is not None: + if os.getenv('TRAVIS') is not None or os.getenv('AZURE') is not None: # Create a shallow clone of the test-data repository (to avoid # downloading more data than is necessary) os.makedirs(dataPath) diff --git a/pyqtgraph/tests/test_configparser.py b/pyqtgraph/tests/test_configparser.py new file mode 100644 index 0000000000..27af9ec71d --- /dev/null +++ b/pyqtgraph/tests/test_configparser.py @@ -0,0 +1,36 @@ +from pyqtgraph import configfile +import numpy as np +import tempfile, os + +def test_longArrays(): + """ + Test config saving and loading of long arrays. + """ + tmp = tempfile.mktemp(".cfg") + + arr = np.arange(20) + configfile.writeConfigFile({'arr':arr}, tmp) + config = configfile.readConfigFile(tmp) + + assert all(config['arr'] == arr) + + os.remove(tmp) + +def test_multipleParameters(): + """ + Test config saving and loading of multiple parameters. + """ + tmp = tempfile.mktemp(".cfg") + + par1 = [1,2,3] + par2 = "Test" + par3 = {'a':3,'b':'c'} + + configfile.writeConfigFile({'par1':par1, 'par2':par2, 'par3':par3}, tmp) + config = configfile.readConfigFile(tmp) + + assert config['par1'] == par1 + assert config['par2'] == par2 + assert config['par3'] == par3 + + os.remove(tmp) diff --git a/pyqtgraph/tests/test_exit_crash.py b/pyqtgraph/tests/test_exit_crash.py index de457d5443..41703ce6cd 100644 --- a/pyqtgraph/tests/test_exit_crash.py +++ b/pyqtgraph/tests/test_exit_crash.py @@ -1,7 +1,13 @@ -import os, sys, subprocess, tempfile +# -*- coding: utf-8 -*- +import os +import sys +import subprocess +import tempfile import pyqtgraph as pg import six import pytest +import textwrap +import time code = """ import sys @@ -14,6 +20,25 @@ skipmessage = ('unclear why this test is failing. skipping until someone has' ' time to fix it') + +def call_with_timeout(*args, **kwargs): + """Mimic subprocess.call with timeout for python < 3.3""" + wait_per_poll = 0.1 + try: + timeout = kwargs.pop('timeout') + except KeyError: + timeout = 10 + + rc = None + p = subprocess.Popen(*args, **kwargs) + for i in range(int(timeout/wait_per_poll)): + rc = p.poll() + if rc is not None: + break + time.sleep(wait_per_poll) + return rc + + @pytest.mark.skipif(True, reason=skipmessage) def test_exit_crash(): # For each Widget subclass, run a simple python script that creates an @@ -35,8 +60,21 @@ def test_exit_crash(): print(name) argstr = initArgs.get(name, "") - open(tmp, 'w').write(code.format(path=path, classname=name, args=argstr)) + with open(tmp, 'w') as f: + f.write(code.format(path=path, classname=name, args=argstr)) proc = subprocess.Popen([sys.executable, tmp]) assert proc.wait() == 0 os.remove(tmp) + +@pytest.mark.skipif(pg.Qt.QtVersion.startswith("5.9"), reason="Functionality not well supported, failing only on this config") +def test_pg_exit(): + # test the pg.exit() function + code = textwrap.dedent(""" + import pyqtgraph as pg + app = pg.mkQApp() + pg.plot() + pg.exit() + """) + rc = call_with_timeout([sys.executable, '-c', code], timeout=5, shell=False) + assert rc == 0 diff --git a/pyqtgraph/tests/test_functions.py b/pyqtgraph/tests/test_functions.py index bfa7e0ea23..f9320ef25f 100644 --- a/pyqtgraph/tests/test_functions.py +++ b/pyqtgraph/tests/test_functions.py @@ -1,10 +1,16 @@ +# -*- coding: utf-8 -*- import pyqtgraph as pg import numpy as np +import sys +from copy import deepcopy +from collections import OrderedDict from numpy.testing import assert_array_almost_equal, assert_almost_equal import pytest + np.random.seed(12345) + def testSolve3D(): p1 = np.array([[0,0,0,1], [1,0,0,1], @@ -22,9 +28,17 @@ def testSolve3D(): assert_array_almost_equal(tr[:3], tr2[:3]) -def test_interpolateArray(): +def test_interpolateArray_order0(): + check_interpolateArray(order=0) + + +def test_interpolateArray_order1(): + check_interpolateArray(order=1) + + +def check_interpolateArray(order): def interpolateArray(data, x): - result = pg.interpolateArray(data, x) + result = pg.interpolateArray(data, x, order=order) assert result.shape == x.shape[:-1] + data.shape[x.shape[-1]:] return result @@ -48,17 +62,17 @@ def interpolateArray(data, x): with pytest.raises(TypeError): interpolateArray(data, np.ones((5, 5, 3,))) - x = np.array([[ 0.3, 0.6], [ 1. , 1. ], - [ 0.5, 1. ], - [ 0.5, 2.5], + [ 0.501, 1. ], # NOTE: testing at exactly 0.5 can yield different results from map_coordinates + [ 0.501, 2.501], # due to differences in rounding [ 10. , 10. ]]) result = interpolateArray(data, x) - #import scipy.ndimage - #spresult = scipy.ndimage.map_coordinates(data, x.T, order=1) - spresult = np.array([ 5.92, 20. , 11. , 0. , 0. ]) # generated with the above line + # make sure results match ndimage.map_coordinates + import scipy.ndimage + spresult = scipy.ndimage.map_coordinates(data, x.T, order=order) + #spresult = np.array([ 5.92, 20. , 11. , 0. , 0. ]) # generated with the above line assert_array_almost_equal(result, spresult) @@ -74,28 +88,17 @@ def interpolateArray(data, x): # test mapping 2D array of locations - x = np.array([[[0.5, 0.5], [0.5, 1.0], [0.5, 1.5]], - [[1.5, 0.5], [1.5, 1.0], [1.5, 1.5]]]) + x = np.array([[[0.501, 0.501], [0.501, 1.0], [0.501, 1.501]], + [[1.501, 0.501], [1.501, 1.0], [1.501, 1.501]]]) r1 = interpolateArray(data, x) - #r2 = scipy.ndimage.map_coordinates(data, x.transpose(2,0,1), order=1) - r2 = np.array([[ 8.25, 11. , 16.5 ], # generated with the above line - [ 82.5 , 110. , 165. ]]) + r2 = scipy.ndimage.map_coordinates(data, x.transpose(2,0,1), order=order) + #r2 = np.array([[ 8.25, 11. , 16.5 ], # generated with the above line + #[ 82.5 , 110. , 165. ]]) assert_array_almost_equal(r1, r2) - # test interpolate where data.ndim > x.shape[1] - - data = np.array([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]]) # 2x2x3 - x = np.array([[1, 1], [0, 0.5], [5, 5]]) - - r1 = interpolateArray(data, x) - assert np.all(r1[0] == data[1, 1]) - assert np.all(r1[1] == 0.5 * (data[0, 0] + data[0, 1])) - assert np.all(r1[2] == 0) - - def test_subArray(): a = np.array([0, 0, 111, 112, 113, 0, 121, 122, 123, 0, 0, 0, 211, 212, 213, 0, 221, 222, 223, 0, 0, 0, 0]) b = pg.subArray(a, offset=2, shape=(2,2,3), stride=(10,4,1)) @@ -208,15 +211,15 @@ def checkImage(img, check, alpha, alphaCheck): # lut smaller than maxint lut = np.arange(128).astype(np.uint8) im2, alpha = pg.makeARGB(im1, lut=lut) - checkImage(im2, np.linspace(0, 127, 256).astype('ubyte'), alpha, False) + checkImage(im2, np.linspace(0, 127.5, 256, dtype='ubyte'), alpha, False) # lut + levels lut = np.arange(256)[::-1].astype(np.uint8) im2, alpha = pg.makeARGB(im1, lut=lut, levels=[-128, 384]) - checkImage(im2, np.linspace(192, 65.5, 256).astype('ubyte'), alpha, False) + checkImage(im2, np.linspace(191.5, 64.5, 256, dtype='ubyte'), alpha, False) im2, alpha = pg.makeARGB(im1, lut=lut, levels=[64, 192]) - checkImage(im2, np.clip(np.linspace(385.5, -126.5, 256), 0, 255).astype('ubyte'), alpha, False) + checkImage(im2, np.clip(np.linspace(384.5, -127.5, 256), 0, 255).astype('ubyte'), alpha, False) # uint8 data + uint16 LUT lut = np.arange(4096)[::-1].astype(np.uint16) // 16 @@ -268,6 +271,30 @@ def checkImage(img, check, alpha, alphaCheck): im2, alpha = pg.makeARGB(im1, lut=lut, levels=(1, 17)) checkImage(im2, np.linspace(127.5, 0, 256).astype('ubyte'), alpha, False) + # nans in image + + # 2d input image, one pixel is nan + im1 = np.ones((10, 12)) + im1[3, 5] = np.nan + im2, alpha = pg.makeARGB(im1, levels=(0, 1)) + assert alpha + assert im2[3, 5, 3] == 0 # nan pixel is transparent + assert im2[0, 0, 3] == 255 # doesn't affect other pixels + + # 3d RGB input image, any color channel of a pixel is nan + im1 = np.ones((10, 12, 3)) + im1[3, 5, 1] = np.nan + im2, alpha = pg.makeARGB(im1, levels=(0, 1)) + assert alpha + assert im2[3, 5, 3] == 0 # nan pixel is transparent + assert im2[0, 0, 3] == 255 # doesn't affect other pixels + + # 3d RGBA input image, any color channel of a pixel is nan + im1 = np.ones((10, 12, 4)) + im1[3, 5, 1] = np.nan + im2, alpha = pg.makeARGB(im1, levels=(0, 1), useRGBA=True) + assert alpha + assert im2[3, 5, 3] == 0 # nan pixel is transparent # test sanity checks class AssertExc(object): @@ -296,6 +323,93 @@ def __exit__(self, *args): with AssertExc(): # 3d levels not allowed pg.makeARGB(np.zeros((2,2,3), dtype='float'), levels=np.zeros([3, 2, 2])) + +def test_eq(): + eq = pg.functions.eq + + zeros = [0, 0.0, np.float(0), np.int(0)] + if sys.version[0] < '3': + zeros.append(long(0)) + for i,x in enumerate(zeros): + for y in zeros[i:]: + assert eq(x, y) + assert eq(y, x) + + assert eq(np.nan, np.nan) + + # test + class NotEq(object): + def __eq__(self, x): + return False + + noteq = NotEq() + assert eq(noteq, noteq) # passes because they are the same object + assert not eq(noteq, NotEq()) + + + # Should be able to test for equivalence even if the test raises certain + # exceptions + class NoEq(object): + def __init__(self, err): + self.err = err + def __eq__(self, x): + raise self.err + + noeq1 = NoEq(AttributeError()) + noeq2 = NoEq(ValueError()) + noeq3 = NoEq(Exception()) + + assert eq(noeq1, noeq1) + assert not eq(noeq1, noeq2) + assert not eq(noeq2, noeq1) + with pytest.raises(Exception): + eq(noeq3, noeq2) + + # test array equivalence + # note that numpy has a weird behavior here--np.all() always returns True + # if one of the arrays has size=0; eq() will only return True if both arrays + # have the same shape. + a1 = np.zeros((10, 20)).astype('float') + a2 = a1 + 1 + a3 = a2.astype('int') + a4 = np.empty((0, 20)) + assert not eq(a1, a2) # same shape/dtype, different values + assert not eq(a1, a3) # same shape, different dtype and values + assert not eq(a1, a4) # different shape (note: np.all gives True if one array has size 0) + + assert not eq(a2, a3) # same values, but different dtype + assert not eq(a2, a4) # different shape + + assert not eq(a3, a4) # different shape and dtype + + assert eq(a4, a4.copy()) + assert not eq(a4, a4.T) + + # test containers + + assert not eq({'a': 1}, {'a': 1, 'b': 2}) + assert not eq({'a': 1}, {'a': 2}) + d1 = {'x': 1, 'y': np.nan, 3: ['a', np.nan, a3, 7, 2.3], 4: a4} + d2 = deepcopy(d1) + assert eq(d1, d2) + d1_ordered = OrderedDict(d1) + d2_ordered = deepcopy(d1_ordered) + assert eq(d1_ordered, d2_ordered) + assert not eq(d1_ordered, d2) + items = list(d1.items()) + assert not eq(OrderedDict(items), OrderedDict(reversed(items))) + + assert not eq([1,2,3], [1,2,3,4]) + l1 = [d1, np.inf, -np.inf, np.nan] + l2 = deepcopy(l1) + t1 = tuple(l1) + t2 = tuple(l2) + assert eq(l1, l2) + assert eq(t1, t2) + + assert eq(set(range(10)), set(range(10))) + assert not eq(set(range(10)), set(range(9))) + if __name__ == '__main__': - test_interpolateArray() \ No newline at end of file + test_interpolateArray() diff --git a/pyqtgraph/tests/test_qt.py b/pyqtgraph/tests/test_qt.py index 5c8800dddd..3ecf9db859 100644 --- a/pyqtgraph/tests/test_qt.py +++ b/pyqtgraph/tests/test_qt.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import pyqtgraph as pg import gc, os import pytest @@ -10,11 +11,16 @@ def test_isQObjectAlive(): o2 = pg.QtCore.QObject() o2.setParent(o1) del o1 - gc.collect() assert not pg.Qt.isQObjectAlive(o2) -@pytest.mark.skipif(pg.Qt.USE_PYSIDE, reason='pysideuic does not appear to be ' - 'packaged with conda') +@pytest.mark.skipif(pg.Qt.QT_LIB == 'PySide', reason='pysideuic does not appear to be ' + 'packaged with conda') +@pytest.mark.skipif( + pg.Qt.QT_LIB == "PySide2" + and tuple(map(int, pg.Qt.PySide2.__version__.split("."))) >= (5, 14) + and tuple(map(int, pg.Qt.PySide2.__version__.split("."))) < (5, 14, 2, 2), + reason="new PySide2 doesn't have loadUi functionality" +) def test_loadUiType(): path = os.path.dirname(__file__) formClass, baseClass = pg.Qt.loadUiType(os.path.join(path, 'uictest.ui')) diff --git a/pyqtgraph/tests/test_ref_cycles.py b/pyqtgraph/tests/test_ref_cycles.py index dec95ef736..121a09e48c 100644 --- a/pyqtgraph/tests/test_ref_cycles.py +++ b/pyqtgraph/tests/test_ref_cycles.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Test for unwanted reference cycles @@ -9,9 +10,7 @@ import pytest app = pg.mkQApp() -skipreason = ('unclear why test is failing on python 3. skipping until someone ' - 'has time to fix it. Or pyside is being used. This test is ' - 'failing on pyside for an unknown reason too.') +skipreason = ('This test is failing on pyside and pyside2 for an unknown reason.') def assert_alldead(refs): for ref in refs: @@ -36,11 +35,10 @@ def mkrefs(*objs): obj = [obj] for o in obj: allObjs[id(o)] = o - - return map(weakref.ref, allObjs.values()) + return [weakref.ref(obj) for obj in allObjs.values()] -@pytest.mark.skipif(six.PY3 or pg.Qt.USE_PYSIDE, reason=skipreason) +@pytest.mark.skipif(pg.Qt.QT_LIB in {'PySide', 'PySide2'}, reason=skipreason) def test_PlotWidget(): def mkobjs(*args, **kwds): w = pg.PlotWidget(*args, **kwds) @@ -58,7 +56,7 @@ def mkobjs(*args, **kwds): for i in range(5): assert_alldead(mkobjs()) -@pytest.mark.skipif(six.PY3 or pg.Qt.USE_PYSIDE, reason=skipreason) +@pytest.mark.skipif(pg.Qt.QT_LIB in {'PySide', 'PySide2'}, reason=skipreason) def test_ImageView(): def mkobjs(): iv = pg.ImageView() @@ -66,12 +64,12 @@ def mkobjs(): iv.setImage(data) return mkrefs(iv, iv.imageItem, iv.view, iv.ui.histogram, data) - for i in range(5): + gc.collect() assert_alldead(mkobjs()) -@pytest.mark.skipif(six.PY3 or pg.Qt.USE_PYSIDE, reason=skipreason) +@pytest.mark.skipif(pg.Qt.QT_LIB in {'PySide', 'PySide2'}, reason=skipreason) def test_GraphicsWindow(): def mkobjs(): w = pg.GraphicsWindow() diff --git a/pyqtgraph/tests/test_reload.py b/pyqtgraph/tests/test_reload.py new file mode 100644 index 0000000000..007e90d217 --- /dev/null +++ b/pyqtgraph/tests/test_reload.py @@ -0,0 +1,117 @@ +import tempfile, os, sys, shutil +import pyqtgraph as pg +import pyqtgraph.reload + + +pgpath = os.path.join(os.path.dirname(pg.__file__), '..') +pgpath_repr = repr(pgpath) + +# make temporary directory to write module code +path = None + +def setup_module(): + # make temporary directory to write module code + global path + path = tempfile.mkdtemp() + sys.path.insert(0, path) + +def teardown_module(): + global path + shutil.rmtree(path) + sys.path.remove(path) + + +code = """ +import sys +sys.path.append({path_repr}) + +import pyqtgraph as pg + +class C(pg.QtCore.QObject): + sig = pg.QtCore.Signal() + def fn(self): + print("{msg}") + +""" + +def remove_cache(mod): + if os.path.isfile(mod+'c'): + os.remove(mod+'c') + cachedir = os.path.join(os.path.dirname(mod), '__pycache__') + if os.path.isdir(cachedir): + shutil.rmtree(cachedir) + + +def test_reload(): + py3 = sys.version_info >= (3,) + + # write a module + mod = os.path.join(path, 'reload_test_mod.py') + print("\nRELOAD FILE:", mod) + open(mod, 'w').write(code.format(path_repr=pgpath_repr, msg="C.fn() Version1")) + + # import the new module + import reload_test_mod + print("RELOAD MOD:", reload_test_mod.__file__) + + c = reload_test_mod.C() + c.sig.connect(c.fn) + if py3: + v1 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, c.sig, c.fn, c.fn.__func__) + else: + v1 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, reload_test_mod.C.fn.__func__, c.sig, c.fn, c.fn.__func__) + + + + # write again and reload + open(mod, 'w').write(code.format(path_repr=pgpath_repr, msg="C.fn() Version2")) + remove_cache(mod) + pg.reload.reloadAll(path, debug=True) + if py3: + v2 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, c.sig, c.fn, c.fn.__func__) + else: + v2 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, reload_test_mod.C.fn.__func__, c.sig, c.fn, c.fn.__func__) + + if not py3: + assert c.fn.im_class is v2[0] + oldcfn = pg.reload.getPreviousVersion(c.fn) + if oldcfn is None: + # Function did not reload; are we using pytest's assertion rewriting? + raise Exception("Function did not reload. (This can happen when using py.test" + " with assertion rewriting; use --assert=plain for this test.)") + if py3: + assert oldcfn.__func__ is v1[2] + else: + assert oldcfn.im_class is v1[0] + assert oldcfn.__func__ is v1[2].__func__ + assert oldcfn.__self__ is c + + + # write again and reload + open(mod, 'w').write(code.format(path_repr=pgpath_repr, msg="C.fn() Version2")) + remove_cache(mod) + pg.reload.reloadAll(path, debug=True) + if py3: + v3 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, c.sig, c.fn, c.fn.__func__) + else: + v3 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, reload_test_mod.C.fn.__func__, c.sig, c.fn, c.fn.__func__) + + #for i in range(len(old)): + #print id(old[i]), id(new1[i]), id(new2[i]), old[i], new1[i] + + cfn1 = pg.reload.getPreviousVersion(c.fn) + cfn2 = pg.reload.getPreviousVersion(cfn1) + + if py3: + assert cfn1.__func__ is v2[2] + assert cfn2.__func__ is v1[2] + else: + assert cfn1.__func__ is v2[2].__func__ + assert cfn2.__func__ is v1[2].__func__ + assert cfn1.im_class is v2[0] + assert cfn2.im_class is v1[0] + assert cfn1.__self__ is c + assert cfn2.__self__ is c + + pg.functions.disconnect(c.sig, c.fn) + diff --git a/pyqtgraph/tests/ui_testing.py b/pyqtgraph/tests/ui_testing.py index 383ba4f994..4bcb760680 100644 --- a/pyqtgraph/tests/ui_testing.py +++ b/pyqtgraph/tests/ui_testing.py @@ -1,11 +1,32 @@ +import time +from ..Qt import QtCore, QtGui, QtTest, QT_LIB + + +def resizeWindow(win, w, h, timeout=2.0): + """Resize a window and wait until it has the correct size. + + This is required for unit testing on some platforms that do not guarantee + immediate response from the windowing system. + """ + QtGui.QApplication.processEvents() + # Sometimes the window size will switch multiple times before settling + # on its final size. Adding qWaitForWindowShown seems to help with this. + QtTest.QTest.qWaitForWindowShown(win) + win.resize(w, h) + start = time.time() + while True: + w1, h1 = win.width(), win.height() + if (w,h) == (w1,h1): + return + QtTest.QTest.qWait(10) + if time.time()-start > timeout: + raise TimeoutError("Window resize failed (requested %dx%d, got %dx%d)" % (w, h, w1, h1)) + # Functions for generating user input events. # We would like to use QTest for this purpose, but it seems to be broken. # See: http://stackoverflow.com/questions/16299779/qt-qgraphicsview-unit-testing-how-to-keep-the-mouse-in-a-pressed-state -from ..Qt import QtCore, QtGui, QT_LIB - - def mousePress(widget, pos, button, modifier=None): if isinstance(widget, QtGui.QGraphicsView): widget = widget.viewport() @@ -52,4 +73,3 @@ def mouseClick(widget, pos, button, modifier=None): mouseMove(widget, pos) mousePress(widget, pos, button, modifier) mouseRelease(widget, pos, button, modifier) - diff --git a/pyqtgraph/tests/uictest.ui b/pyqtgraph/tests/uictest.ui index 25d14f2b52..a183bdaef2 100644 --- a/pyqtgraph/tests/uictest.ui +++ b/pyqtgraph/tests/uictest.ui @@ -11,7 +11,7 @@ - Form + PyQtGraph diff --git a/pyqtgraph/util/get_resolution.py b/pyqtgraph/util/get_resolution.py new file mode 100644 index 0000000000..79e1717057 --- /dev/null +++ b/pyqtgraph/util/get_resolution.py @@ -0,0 +1,15 @@ +from .. import mkQApp + +def test_screenInformation(): + qApp = mkQApp() + desktop = qApp.desktop() + resolution = desktop.screenGeometry() + availableResolution = desktop.availableGeometry() + print("Screen resolution: {}x{}".format(resolution.width(), resolution.height())) + print("Available geometry: {}x{}".format(availableResolution.width(), availableResolution.height())) + print("Number of Screens: {}".format(desktop.screenCount())) + return None + + +if __name__ == "__main__": + test_screenInformation() \ No newline at end of file diff --git a/pyqtgraph/util/lru_cache.py b/pyqtgraph/util/lru_cache.py index 9c04abf37c..5300e0ff4e 100644 --- a/pyqtgraph/util/lru_cache.py +++ b/pyqtgraph/util/lru_cache.py @@ -80,7 +80,7 @@ def _resizeTo(self): for i in ordered: del self._dict[i[0]] - def iteritems(self, accessTime=False): + def items(self, accessTime=False): ''' :param bool accessTime: If True sorts the returned items by the internal access time. @@ -94,18 +94,18 @@ def iteritems(self, accessTime=False): else: def values(self): - return [i[1] for i in self._dict.itervalues()] + return [i[1] for i in self._dict.values()] def keys(self): - return [x[0] for x in self._dict.itervalues()] + return [x[0] for x in self._dict.values()] def _resizeTo(self): - ordered = sorted(self._dict.itervalues(), key=operator.itemgetter(2))[:self.resizeTo] + ordered = sorted(self._dict.values(), key=operator.itemgetter(2))[:self.resizeTo] for i in ordered: del self._dict[i[0]] - def iteritems(self, accessTime=False): + def items(self, accessTime=False): ''' ============= ====================================================== **Arguments** @@ -114,8 +114,8 @@ def iteritems(self, accessTime=False): ============= ====================================================== ''' if accessTime: - for x in sorted(self._dict.itervalues(), key=operator.itemgetter(2)): + for x in sorted(self._dict.values(), key=operator.itemgetter(2)): yield x[0], x[1] else: - for x in self._dict.iteritems(): + for x in self._dict.items(): yield x[0], x[1] diff --git a/pyqtgraph/util/mutex.py b/pyqtgraph/util/mutex.py index 4a193127fb..c03c65c4af 100644 --- a/pyqtgraph/util/mutex.py +++ b/pyqtgraph/util/mutex.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -from ..Qt import QtCore import traceback +from ..Qt import QtCore + class Mutex(QtCore.QMutex): """ @@ -17,7 +18,7 @@ def __init__(self, *args, **kargs): QtCore.QMutex.__init__(self, *args) self.l = QtCore.QMutex() ## for serializing access to self.tb self.tb = [] - self.debug = True ## True to enable debugging functions + self.debug = kargs.pop('debug', False) ## True to enable debugging functions def tryLock(self, timeout=None, id=None): if timeout is None: @@ -72,6 +73,16 @@ def unlock(self): finally: self.l.unlock() + def acquire(self, blocking=True): + """Mimics threading.Lock.acquire() to allow this class as a drop-in replacement. + """ + return self.tryLock() + + def release(self): + """Mimics threading.Lock.release() to allow this class as a drop-in replacement. + """ + self.unlock() + def depth(self): self.l.lock() n = len(self.tb) @@ -91,4 +102,13 @@ def __exit__(self, *args): def __enter__(self): self.lock() - return self \ No newline at end of file + return self + + +class RecursiveMutex(Mutex): + """Mimics threading.RLock class. + """ + def __init__(self, **kwds): + kwds['recursive'] = True + Mutex.__init__(self, **kwds) + diff --git a/pyqtgraph/util/tests/test_lru_cache.py b/pyqtgraph/util/tests/test_lru_cache.py index c0cf9f8aaa..94451d9713 100644 --- a/pyqtgraph/util/tests/test_lru_cache.py +++ b/pyqtgraph/util/tests/test_lru_cache.py @@ -22,28 +22,28 @@ def checkLru(lru): set([2, 1]) == set(lru.values()) #Iterates from the used in the last access to others based on access time. - assert [(2, 2), (1, 1)] == list(lru.iteritems(accessTime=True)) + assert [(2, 2), (1, 1)] == list(lru.items(accessTime=True)) lru[2] = 2 - assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) del lru[2] - assert [(1, 1), ] == list(lru.iteritems(accessTime=True)) + assert [(1, 1), ] == list(lru.items(accessTime=True)) lru[2] = 2 - assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) _a = lru[1] - assert [(2, 2), (1, 1)] == list(lru.iteritems(accessTime=True)) + assert [(2, 2), (1, 1)] == list(lru.items(accessTime=True)) _a = lru[2] - assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) assert lru.get(2) == 2 assert lru.get(3) == None - assert [(1, 1), (2, 2)] == list(lru.iteritems(accessTime=True)) + assert [(1, 1), (2, 2)] == list(lru.items(accessTime=True)) lru.clear() - assert [] == list(lru.iteritems()) + assert [] == list(lru.items()) if __name__ == '__main__': diff --git a/pyqtgraph/widgets/BusyCursor.py b/pyqtgraph/widgets/BusyCursor.py index d99fe589ff..f6bbc84cdf 100644 --- a/pyqtgraph/widgets/BusyCursor.py +++ b/pyqtgraph/widgets/BusyCursor.py @@ -1,4 +1,4 @@ -from ..Qt import QtGui, QtCore +from ..Qt import QtGui, QtCore, QT_LIB __all__ = ['BusyCursor'] @@ -9,16 +9,26 @@ class BusyCursor(object): with pyqtgraph.BusyCursor(): doLongOperation() - May be nested. + May be nested. If called from a non-gui thread, then the cursor will not be affected. """ active = [] def __enter__(self): - QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor)) - BusyCursor.active.append(self) + app = QtCore.QCoreApplication.instance() + isGuiThread = (app is not None) and (QtCore.QThread.currentThread() == app.thread()) + if isGuiThread and QtGui.QApplication.instance() is not None: + if QT_LIB == 'PySide': + # pass CursorShape rather than QCursor for PySide + # see https://bugreports.qt.io/browse/PYSIDE-243 + QtGui.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) + else: + QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor)) + BusyCursor.active.append(self) + self._active = True + else: + self._active = False def __exit__(self, *args): - BusyCursor.active.pop(-1) - if len(BusyCursor.active) == 0: + if self._active: + BusyCursor.active.pop(-1) QtGui.QApplication.restoreOverrideCursor() - \ No newline at end of file diff --git a/pyqtgraph/widgets/ColorButton.py b/pyqtgraph/widgets/ColorButton.py index a0bb0c8eb1..43dd16f60d 100644 --- a/pyqtgraph/widgets/ColorButton.py +++ b/pyqtgraph/widgets/ColorButton.py @@ -50,11 +50,11 @@ def paintEvent(self, ev): def setColor(self, color, finished=True): """Sets the button's color and emits both sigColorChanged and sigColorChanging.""" self._color = functions.mkColor(color) + self.update() if finished: self.sigColorChanged.emit(self) else: self.sigColorChanging.emit(self) - self.update() def selectColor(self): self.origColor = self.color() diff --git a/pyqtgraph/widgets/ColorMapWidget.py b/pyqtgraph/widgets/ColorMapWidget.py index f6e289608b..b5e25d943d 100644 --- a/pyqtgraph/widgets/ColorMapWidget.py +++ b/pyqtgraph/widgets/ColorMapWidget.py @@ -42,6 +42,11 @@ def saveState(self): def restoreState(self, state): self.params.restoreState(state) + def addColorMap(self, name): + """Add a new color mapping and return the created parameter. + """ + return self.params.addNew(name) + class ColorMapParameter(ptree.types.GroupParameter): sigColorMapChanged = QtCore.Signal(object) @@ -55,11 +60,21 @@ def mapChanged(self): self.sigColorMapChanged.emit(self) def addNew(self, name): - mode = self.fields[name].get('mode', 'range') + fieldSpec = self.fields[name] + + mode = fieldSpec.get('mode', 'range') if mode == 'range': item = RangeColorMapItem(name, self.fields[name]) elif mode == 'enum': item = EnumColorMapItem(name, self.fields[name]) + + defaults = fieldSpec.get('defaults', {}) + for k, v in defaults.items(): + if k == 'colormap': + item.setValue(v) + else: + item[k] = v + self.addChild(item) return item @@ -85,6 +100,11 @@ def setFields(self, fields): values List of unique values for which the user may assign a color when mode=='enum'. Optionally may specify a dict instead {value: name}. + defaults Dict of default values to apply to color map items when + they are created. Valid keys are 'colormap' to provide + a default color map, or otherwise they a string or tuple + indicating the parameter to be set, such as 'Operation' or + ('Channels..', 'Red'). ============== ============================================================ """ self.fields = OrderedDict(fields) @@ -131,8 +151,7 @@ def map(self, data, mode='byte'): c3[:,3:4] = colors[:,3:4] + (1-colors[:,3:4]) * a colors = c3 elif op == 'Set': - colors[mask] = colors2[mask] - + colors[mask] = colors2[mask] colors = np.clip(colors, 0, 1) if mode == 'byte': @@ -152,7 +171,7 @@ def saveState(self): def restoreState(self, state): if 'fields' in state: self.setFields(state['fields']) - for itemState in state['items']: + for name, itemState in state['items'].items(): item = self.addNew(itemState['field']) item.restoreState(itemState) @@ -179,7 +198,7 @@ def __init__(self, name, opts): dict(name='Enabled', type='bool', value=True), dict(name='NaN', type='color'), ]) - + def map(self, data): data = data[self.fieldName] diff --git a/pyqtgraph/widgets/ComboBox.py b/pyqtgraph/widgets/ComboBox.py index a6828959f6..6f184c5fe8 100644 --- a/pyqtgraph/widgets/ComboBox.py +++ b/pyqtgraph/widgets/ComboBox.py @@ -102,7 +102,7 @@ def fn(self, *args, **kwds): @blockIfUnchanged def setItems(self, items): """ - *items* may be a list or a dict. + *items* may be a list, a tuple, or a dict. If a dict is given, then the keys are used to populate the combo box and the values will be used for both value() and setValue(). """ @@ -191,13 +191,13 @@ def setItemValue(self, name, value): @ignoreIndexChange @blockIfUnchanged def addItems(self, items): - if isinstance(items, list): + if isinstance(items, list) or isinstance(items, tuple): texts = items items = dict([(x, x) for x in items]) elif isinstance(items, dict): texts = list(items.keys()) else: - raise TypeError("items argument must be list or dict (got %s)." % type(items)) + raise TypeError("items argument must be list or dict or tuple (got %s)." % type(items)) for t in texts: if t in self._items: @@ -216,3 +216,30 @@ def clear(self): QtGui.QComboBox.clear(self) self.itemsChanged() + def saveState(self): + ind = self.currentIndex() + data = self.itemData(ind) + #if not data.isValid(): + if data is not None: + try: + if not data.isValid(): + data = None + else: + data = data.toInt()[0] + except AttributeError: + pass + if data is None: + return asUnicode(self.itemText(ind)) + else: + return data + + def restoreState(self, v): + if type(v) is int: + ind = self.findData(v) + if ind > -1: + self.setCurrentIndex(ind) + return + self.setCurrentIndex(self.findText(str(v))) + + def widgetGroupInterface(self): + return (self.currentIndexChanged, self.saveState, self.restoreState) diff --git a/pyqtgraph/widgets/DataFilterWidget.py b/pyqtgraph/widgets/DataFilterWidget.py index cae8be86ad..6421d71b98 100644 --- a/pyqtgraph/widgets/DataFilterWidget.py +++ b/pyqtgraph/widgets/DataFilterWidget.py @@ -3,13 +3,18 @@ import numpy as np from ..pgcollections import OrderedDict from .. import functions as fn +from ..python2_3 import basestring __all__ = ['DataFilterWidget'] + class DataFilterWidget(ptree.ParameterTree): """ This class allows the user to filter multi-column data sets by specifying multiple criteria + + Wraps methods from DataFilterParameter: setFields, generateMask, + filterData, and describe. """ sigFilterChanged = QtCore.Signal(object) @@ -19,21 +24,25 @@ def __init__(self): self.params = DataFilterParameter() self.setParameters(self.params) - self.params.sigTreeStateChanged.connect(self.filterChanged) + self.params.sigFilterChanged.connect(self.sigFilterChanged) self.setFields = self.params.setFields + self.generateMask = self.params.generateMask self.filterData = self.params.filterData self.describe = self.params.describe - def filterChanged(self): - self.sigFilterChanged.emit(self) - def parameters(self): return self.params - + + def addFilter(self, name): + """Add a new filter and return the created parameter item. + """ + return self.params.addNew(name) + class DataFilterParameter(ptree.types.GroupParameter): - + """A parameter group that specifies a set of filters to apply to tabular data. + """ sigFilterChanged = QtCore.Signal(object) def __init__(self): @@ -47,18 +56,38 @@ def filterChanged(self): def addNew(self, name): mode = self.fields[name].get('mode', 'range') if mode == 'range': - self.addChild(RangeFilterItem(name, self.fields[name])) + child = self.addChild(RangeFilterItem(name, self.fields[name])) elif mode == 'enum': - self.addChild(EnumFilterItem(name, self.fields[name])) - + child = self.addChild(EnumFilterItem(name, self.fields[name])) + return child def fieldNames(self): return self.fields.keys() def setFields(self, fields): - self.fields = OrderedDict(fields) - names = self.fieldNames() - self.setAddList(names) + """Set the list of fields that are available to be filtered. + + *fields* must be a dict or list of tuples that maps field names + to a specification describing the field. Each specification is + itself a dict with either ``'mode':'range'`` or ``'mode':'enum'``:: + + filter.setFields([ + ('field1', {'mode': 'range'}), + ('field2', {'mode': 'enum', 'values': ['val1', 'val2', 'val3']}), + ('field3', {'mode': 'enum', 'values': {'val1':True, 'val2':False, 'val3':True}}), + ]) + """ + with fn.SignalBlock(self.sigTreeStateChanged, self.filterChanged): + self.fields = OrderedDict(fields) + names = self.fieldNames() + self.setAddList(names) + + # update any existing filters + for ch in self.children(): + name = ch.fieldName + if name in fields: + ch.updateFilter(fields[name]) + self.sigFilterChanged.emit(self) def filterData(self, data): if len(data) == 0: @@ -66,6 +95,9 @@ def filterData(self, data): return data[self.generateMask(data)] def generateMask(self, data): + """Return a boolean mask indicating whether each item in *data* passes + the filter critera. + """ mask = np.ones(len(data), dtype=bool) if len(data) == 0: return mask @@ -89,6 +121,7 @@ def describe(self): desc.append(fp.describe()) return desc + class RangeFilterItem(ptree.types.SimpleParameter): def __init__(self, name, opts): self.fieldName = name @@ -109,25 +142,17 @@ def generateMask(self, data, mask): def describe(self): return "%s < %s < %s" % (fn.siFormat(self['Min'], suffix=self.units), self.fieldName, fn.siFormat(self['Max'], suffix=self.units)) + + def updateFilter(self, opts): + pass + class EnumFilterItem(ptree.types.SimpleParameter): def __init__(self, name, opts): self.fieldName = name - vals = opts.get('values', []) - childs = [] - if isinstance(vals, list): - vals = OrderedDict([(v,str(v)) for v in vals]) - for val,vname in vals.items(): - ch = ptree.Parameter.create(name=vname, type='bool', value=True) - ch.maskValue = val - childs.append(ch) - ch = ptree.Parameter.create(name='(other)', type='bool', value=True) - ch.maskValue = '__other__' - childs.append(ch) - ptree.types.SimpleParameter.__init__(self, - name=name, autoIncrementName=True, type='bool', value=True, removable=True, renamable=True, - children=childs) + name=name, autoIncrementName=True, type='bool', value=True, removable=True, renamable=True) + self.setEnumVals(opts) def generateMask(self, data, startMask): vals = data[self.fieldName][startMask] @@ -147,4 +172,38 @@ def generateMask(self, data, startMask): def describe(self): vals = [ch.name() for ch in self if ch.value() is True] - return "%s: %s" % (self.fieldName, ', '.join(vals)) \ No newline at end of file + return "%s: %s" % (self.fieldName, ', '.join(vals)) + + def updateFilter(self, opts): + self.setEnumVals(opts) + + def setEnumVals(self, opts): + vals = opts.get('values', {}) + + prevState = {} + for ch in self.children(): + prevState[ch.name()] = ch.value() + self.removeChild(ch) + + if not isinstance(vals, dict): + vals = OrderedDict([(v,(str(v), True)) for v in vals]) + + # Each filterable value can come with either (1) a string name, (2) a bool + # indicating whether the value is enabled by default, or (3) a tuple providing + # both. + for val,valopts in vals.items(): + if isinstance(valopts, bool): + enabled = valopts + vname = str(val) + elif isinstance(valopts, basestring): + enabled = True + vname = valopts + elif isinstance(valopts, tuple): + vname, enabled = valopts + + ch = ptree.Parameter.create(name=vname, type='bool', value=prevState.get(vname, enabled)) + ch.maskValue = val + self.addChild(ch) + ch = ptree.Parameter.create(name='(other)', type='bool', value=prevState.get('(other)', True)) + ch.maskValue = '__other__' + self.addChild(ch) diff --git a/pyqtgraph/widgets/DataTreeWidget.py b/pyqtgraph/widgets/DataTreeWidget.py index 29e603191a..39cb0d4562 100644 --- a/pyqtgraph/widgets/DataTreeWidget.py +++ b/pyqtgraph/widgets/DataTreeWidget.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from ..Qt import QtGui, QtCore from ..pgcollections import OrderedDict +from .TableWidget import TableWidget +from ..python2_3 import asUnicode import types, traceback import numpy as np @@ -17,67 +19,106 @@ class DataTreeWidget(QtGui.QTreeWidget): Widget for displaying hierarchical python data structures (eg, nested dicts, lists, and arrays) """ - - def __init__(self, parent=None, data=None): QtGui.QTreeWidget.__init__(self, parent) self.setVerticalScrollMode(self.ScrollPerPixel) self.setData(data) self.setColumnCount(3) self.setHeaderLabels(['key / index', 'type', 'value']) + self.setAlternatingRowColors(True) def setData(self, data, hideRoot=False): """data should be a dictionary.""" self.clear() + self.widgets = [] + self.nodes = {} self.buildTree(data, self.invisibleRootItem(), hideRoot=hideRoot) - #node = self.mkNode('', data) - #while node.childCount() > 0: - #c = node.child(0) - #node.removeChild(c) - #self.invisibleRootItem().addChild(c) self.expandToDepth(3) self.resizeColumnToContents(0) - def buildTree(self, data, parent, name='', hideRoot=False): + def buildTree(self, data, parent, name='', hideRoot=False, path=()): if hideRoot: node = parent else: - typeStr = type(data).__name__ - if typeStr == 'instance': - typeStr += ": " + data.__class__.__name__ - node = QtGui.QTreeWidgetItem([name, typeStr, ""]) + node = QtGui.QTreeWidgetItem([name, "", ""]) parent.addChild(node) - if isinstance(data, types.TracebackType): ## convert traceback to a list of strings - data = list(map(str.strip, traceback.format_list(traceback.extract_tb(data)))) - elif HAVE_METAARRAY and (hasattr(data, 'implements') and data.implements('MetaArray')): - data = { - 'data': data.view(np.ndarray), - 'meta': data.infoCopy() - } + # record the path to the node so it can be retrieved later + # (this is used by DiffTreeWidget) + self.nodes[path] = node + + typeStr, desc, childs, widget = self.parse(data) + node.setText(1, typeStr) + node.setText(2, desc) - if isinstance(data, dict): - for k in data.keys(): - self.buildTree(data[k], node, str(k)) - elif isinstance(data, list) or isinstance(data, tuple): - for i in range(len(data)): - self.buildTree(data[i], node, str(i)) - else: - node.setText(2, str(data)) + # Truncate description and add text box if needed + if len(desc) > 100: + desc = desc[:97] + '...' + if widget is None: + widget = QtGui.QPlainTextEdit(asUnicode(data)) + widget.setMaximumHeight(200) + widget.setReadOnly(True) + # Add widget to new subnode + if widget is not None: + self.widgets.append(widget) + subnode = QtGui.QTreeWidgetItem(["", "", ""]) + node.addChild(subnode) + self.setItemWidget(subnode, 0, widget) + self.setFirstItemColumnSpanned(subnode, True) + + # recurse to children + for key, data in childs.items(): + self.buildTree(data, node, asUnicode(key), path=path+(key,)) + + def parse(self, data): + """ + Given any python object, return: + * type + * a short string representation + * a dict of sub-objects to be parsed + * optional widget to display as sub-node + """ + # defaults for all objects + typeStr = type(data).__name__ + if typeStr == 'instance': + typeStr += ": " + data.__class__.__name__ + widget = None + desc = "" + childs = {} - #def mkNode(self, name, v): - #if type(v) is list and len(v) > 0 and isinstance(v[0], dict): - #inds = map(unicode, range(len(v))) - #v = OrderedDict(zip(inds, v)) - #if isinstance(v, dict): - ##print "\nadd tree", k, v - #node = QtGui.QTreeWidgetItem([name]) - #for k in v: - #newNode = self.mkNode(k, v[k]) - #node.addChild(newNode) - #else: - ##print "\nadd value", k, str(v) - #node = QtGui.QTreeWidgetItem([unicode(name), unicode(v)]) - #return node + # type-specific changes + if isinstance(data, dict): + desc = "length=%d" % len(data) + if isinstance(data, OrderedDict): + childs = data + else: + childs = OrderedDict(sorted(data.items())) + elif isinstance(data, (list, tuple)): + desc = "length=%d" % len(data) + childs = OrderedDict(enumerate(data)) + elif HAVE_METAARRAY and (hasattr(data, 'implements') and data.implements('MetaArray')): + childs = OrderedDict([ + ('data', data.view(np.ndarray)), + ('meta', data.infoCopy()) + ]) + elif isinstance(data, np.ndarray): + desc = "shape=%s dtype=%s" % (data.shape, data.dtype) + table = TableWidget() + table.setData(data) + table.setMaximumHeight(200) + widget = table + elif isinstance(data, types.TracebackType): ## convert traceback to a list of strings + frames = list(map(str.strip, traceback.format_list(traceback.extract_tb(data)))) + #childs = OrderedDict([ + #(i, {'file': child[0], 'line': child[1], 'function': child[2], 'code': child[3]}) + #for i, child in enumerate(frames)]) + #childs = OrderedDict([(i, ch) for i,ch in enumerate(frames)]) + widget = QtGui.QPlainTextEdit(asUnicode('\n'.join(frames))) + widget.setMaximumHeight(200) + widget.setReadOnly(True) + else: + desc = asUnicode(data) + return typeStr, desc, childs, widget + \ No newline at end of file diff --git a/pyqtgraph/widgets/DiffTreeWidget.py b/pyqtgraph/widgets/DiffTreeWidget.py new file mode 100644 index 0000000000..eac2948989 --- /dev/null +++ b/pyqtgraph/widgets/DiffTreeWidget.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +from ..Qt import QtGui, QtCore +from ..pgcollections import OrderedDict +from .DataTreeWidget import DataTreeWidget +from .. import functions as fn +import types, traceback +import numpy as np + +__all__ = ['DiffTreeWidget'] + + +class DiffTreeWidget(QtGui.QWidget): + """ + Widget for displaying differences between hierarchical python data structures + (eg, nested dicts, lists, and arrays) + """ + def __init__(self, parent=None, a=None, b=None): + QtGui.QWidget.__init__(self, parent) + self.layout = QtGui.QHBoxLayout() + self.setLayout(self.layout) + self.trees = [DataTreeWidget(self), DataTreeWidget(self)] + for t in self.trees: + self.layout.addWidget(t) + if a is not None: + self.setData(a, b) + + def setData(self, a, b): + """ + Set the data to be compared in this widget. + """ + self.data = (a, b) + self.trees[0].setData(a) + self.trees[1].setData(b) + + return self.compare(a, b) + + def compare(self, a, b, path=()): + """ + Compare data structure *a* to structure *b*. + + Return True if the objects match completely. + Otherwise, return a structure that describes the differences: + + { 'type': bool + 'len': bool, + 'str': bool, + 'shape': bool, + 'dtype': bool, + 'mask': array, + } + + + """ + bad = (255, 200, 200) + diff = [] + # generate typestr, desc, childs for each object + typeA, descA, childsA, _ = self.trees[0].parse(a) + typeB, descB, childsB, _ = self.trees[1].parse(b) + + if typeA != typeB: + self.setColor(path, 1, bad) + if descA != descB: + self.setColor(path, 2, bad) + + if isinstance(a, dict) and isinstance(b, dict): + keysA = set(a.keys()) + keysB = set(b.keys()) + for key in keysA - keysB: + self.setColor(path+(key,), 0, bad, tree=0) + for key in keysB - keysA: + self.setColor(path+(key,), 0, bad, tree=1) + for key in keysA & keysB: + self.compare(a[key], b[key], path+(key,)) + + elif isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)): + for i in range(max(len(a), len(b))): + if len(a) <= i: + self.setColor(path+(i,), 0, bad, tree=1) + elif len(b) <= i: + self.setColor(path+(i,), 0, bad, tree=0) + else: + self.compare(a[i], b[i], path+(i,)) + + elif isinstance(a, np.ndarray) and isinstance(b, np.ndarray) and a.shape == b.shape: + tableNodes = [tree.nodes[path].child(0) for tree in self.trees] + if a.dtype.fields is None and b.dtype.fields is None: + eq = self.compareArrays(a, b) + if not np.all(eq): + for n in tableNodes: + n.setBackground(0, fn.mkBrush(bad)) + #for i in np.argwhere(~eq): + + else: + if a.dtype == b.dtype: + for i,k in enumerate(a.dtype.fields.keys()): + eq = self.compareArrays(a[k], b[k]) + if not np.all(eq): + for n in tableNodes: + n.setBackground(0, fn.mkBrush(bad)) + #for j in np.argwhere(~eq): + + # dict: compare keys, then values where keys match + # list: + # array: compare elementwise for same shape + + def compareArrays(self, a, b): + intnan = -9223372036854775808 # happens when np.nan is cast to int + anans = np.isnan(a) | (a == intnan) + bnans = np.isnan(b) | (b == intnan) + eq = anans == bnans + mask = ~anans + eq[mask] = np.allclose(a[mask], b[mask]) + return eq + + def setColor(self, path, column, color, tree=None): + brush = fn.mkBrush(color) + + # Color only one tree if specified. + if tree is None: + trees = self.trees + else: + trees = [self.trees[tree]] + + for tree in trees: + item = tree.nodes[path] + item.setBackground(column, brush) + + def _compare(self, a, b): + """ + Compare data structure *a* to structure *b*. + """ + # Check test structures are the same + assert type(info) is type(expect) + if hasattr(info, '__len__'): + assert len(info) == len(expect) + + if isinstance(info, dict): + for k in info: + assert k in expect + for k in expect: + assert k in info + self.compare_results(info[k], expect[k]) + elif isinstance(info, list): + for i in range(len(info)): + self.compare_results(info[i], expect[i]) + elif isinstance(info, np.ndarray): + assert info.shape == expect.shape + assert info.dtype == expect.dtype + if info.dtype.fields is None: + intnan = -9223372036854775808 # happens when np.nan is cast to int + inans = np.isnan(info) | (info == intnan) + enans = np.isnan(expect) | (expect == intnan) + assert np.all(inans == enans) + mask = ~inans + assert np.allclose(info[mask], expect[mask]) + else: + for k in info.dtype.fields.keys(): + self.compare_results(info[k], expect[k]) + else: + try: + assert info == expect + except Exception: + raise NotImplementedError("Cannot compare objects of type %s" % type(info)) + \ No newline at end of file diff --git a/pyqtgraph/widgets/GraphicsLayoutWidget.py b/pyqtgraph/widgets/GraphicsLayoutWidget.py index ec7b9e0d76..6249ba267d 100644 --- a/pyqtgraph/widgets/GraphicsLayoutWidget.py +++ b/pyqtgraph/widgets/GraphicsLayoutWidget.py @@ -1,4 +1,5 @@ -from ..Qt import QtGui +# -*- coding: utf-8 -*- +from ..Qt import QtGui, mkQApp from ..graphicsItems.GraphicsLayout import GraphicsLayout from .GraphicsView import GraphicsView @@ -9,6 +10,30 @@ class GraphicsLayoutWidget(GraphicsView): ` with a single :class:`GraphicsLayout ` as its central item. + This widget is an easy starting point for generating multi-panel figures. + Example:: + + w = pg.GraphicsLayoutWidget() + p1 = w.addPlot(row=0, col=0) + p2 = w.addPlot(row=0, col=1) + v = w.addViewBox(row=1, col=0, colspan=2) + + ========= ================================================================= + parent (QWidget or None) The parent widget. + show (bool) If True, then immediately show the widget after it is + created. If the widget has no parent, then it will be shown + inside a new window. + size (width, height) tuple. Optionally resize the widget. Note: if + this widget is placed inside a layout, then this argument has no + effect. + title (str or None) If specified, then set the window title for this + widget. + kargs All extra arguments are passed to + :meth:`GraphicsLayout.__init__ + ` + ========= ================================================================= + + This class wraps several methods from its internal GraphicsLayout: :func:`nextRow ` :func:`nextColumn ` @@ -22,9 +47,19 @@ class GraphicsLayoutWidget(GraphicsView): :func:`itemIndex ` :func:`clear ` """ - def __init__(self, parent=None, **kargs): + def __init__(self, parent=None, show=False, size=None, title=None, **kargs): + mkQApp() GraphicsView.__init__(self, parent) self.ci = GraphicsLayout(**kargs) for n in ['nextRow', 'nextCol', 'nextColumn', 'addPlot', 'addViewBox', 'addItem', 'getItem', 'addLayout', 'addLabel', 'removeItem', 'itemIndex', 'clear']: setattr(self, n, getattr(self.ci, n)) self.setCentralItem(self.ci) + + if size is not None: + self.resize(*size) + + if title is not None: + self.setWindowTitle(title) + + if show is True: + self.show() diff --git a/pyqtgraph/widgets/GraphicsView.py b/pyqtgraph/widgets/GraphicsView.py index 92547ef0b7..86b43222d1 100644 --- a/pyqtgraph/widgets/GraphicsView.py +++ b/pyqtgraph/widgets/GraphicsView.py @@ -5,7 +5,7 @@ Distributed under MIT/X11 license. See license.txt for more information. """ -from ..Qt import QtCore, QtGui, USE_PYSIDE +from ..Qt import QtCore, QtGui, QT_LIB try: from ..Qt import QtOpenGL @@ -15,6 +15,7 @@ from ..Point import Point import sys, os +import warnings from .FileDialog import FileDialog from ..GraphicsScene import GraphicsScene import numpy as np @@ -115,7 +116,7 @@ def __init__(self, parent=None, useOpenGL=None, background='default'): ## Workaround for PySide crash ## This ensures that the scene will outlive the view. - if USE_PYSIDE: + if QT_LIB == 'PySide': self.sceneObj._view_ref_workaround = self ## by default we set up a central widget with a grid layout. @@ -227,12 +228,12 @@ def updateMatrix(self, propagate=True): else: self.fitInView(self.range, QtCore.Qt.IgnoreAspectRatio) - self.sigDeviceRangeChanged.emit(self, self.range) - self.sigDeviceTransformChanged.emit(self) - if propagate: for v in self.lockedViewports: v.setXRange(self.range, padding=0) + + self.sigDeviceRangeChanged.emit(self, self.range) + self.sigDeviceTransformChanged.emit(self) def viewRect(self): """Return the boundaries of the view in scene coordinates""" @@ -262,7 +263,6 @@ def scale(self, sx, sy, center=None): h = self.range.height() / scale[1] self.range = QtCore.QRectF(center.x() - (center.x()-self.range.left()) / scale[0], center.y() - (center.y()-self.range.top()) /scale[1], w, h) - self.updateMatrix() self.sigScaleChanged.emit(self) @@ -327,7 +327,16 @@ def wheelEvent(self, ev): if not self.mouseEnabled: ev.ignore() return - sc = 1.001 ** ev.delta() + + delta = 0 + if QT_LIB in ['PyQt4', 'PySide']: + delta = ev.delta() + else: + delta = ev.angleDelta().x() + if delta == 0: + delta = ev.angleDelta().y() + + sc = 1.001 ** delta #self.scale *= sc #self.updateMatrix() self.scale(sc, sc) @@ -362,7 +371,7 @@ def mouseReleaseEvent(self, ev): def mouseMoveEvent(self, ev): if self.lastMousePos is None: self.lastMousePos = Point(ev.pos()) - delta = Point(ev.pos() - self.lastMousePos) + delta = Point(ev.pos() - QtCore.QPoint(*self.lastMousePos)) self.lastMousePos = Point(ev.pos()) QtGui.QGraphicsView.mouseMoveEvent(self, ev) @@ -397,5 +406,18 @@ def pixelSize(self): def dragEnterEvent(self, ev): ev.ignore() ## not sure why, but for some reason this class likes to consume drag events - + def _del(self): + try: + if self.parentWidget() is None and self.isVisible(): + msg = "Visible window deleted. To prevent this, store a reference to the window object." + try: + warnings.warn(msg, RuntimeWarning, stacklevel=2) + except TypeError: + # warnings module not available during interpreter shutdown + pass + except RuntimeError: + pass + +if sys.version_info[0] == 3 and sys.version_info[1] >= 4: + GraphicsView.__del__ = GraphicsView._del diff --git a/pyqtgraph/widgets/GroupBox.py b/pyqtgraph/widgets/GroupBox.py new file mode 100644 index 0000000000..432501154e --- /dev/null +++ b/pyqtgraph/widgets/GroupBox.py @@ -0,0 +1,93 @@ +from ..Qt import QtGui, QtCore +from .PathButton import PathButton +from ..python2_3 import basestring + + +class GroupBox(QtGui.QGroupBox): + """Subclass of QGroupBox that implements collapse handle. + """ + sigCollapseChanged = QtCore.Signal(object) + + def __init__(self, *args): + QtGui.QGroupBox.__init__(self, *args) + + self._collapsed = False + # We modify the size policy when the group box is collapsed, so + # keep track of the last requested policy: + self._lastSizePlocy = self.sizePolicy() + + self.closePath = QtGui.QPainterPath() + self.closePath.moveTo(0, -1) + self.closePath.lineTo(0, 1) + self.closePath.lineTo(1, 0) + self.closePath.lineTo(0, -1) + + self.openPath = QtGui.QPainterPath() + self.openPath.moveTo(-1, 0) + self.openPath.lineTo(1, 0) + self.openPath.lineTo(0, 1) + self.openPath.lineTo(-1, 0) + + self.collapseBtn = PathButton(path=self.openPath, size=(12, 12), margin=0) + self.collapseBtn.setStyleSheet(""" + border: none; + """) + self.collapseBtn.setPen('k') + self.collapseBtn.setBrush('w') + self.collapseBtn.setParent(self) + self.collapseBtn.move(3, 3) + self.collapseBtn.setFlat(True) + + self.collapseBtn.clicked.connect(self.toggleCollapsed) + + if len(args) > 0 and isinstance(args[0], basestring): + self.setTitle(args[0]) + + def toggleCollapsed(self): + self.setCollapsed(not self._collapsed) + + def collapsed(self): + return self._collapsed + + def setCollapsed(self, c): + if c == self._collapsed: + return + + if c is True: + self.collapseBtn.setPath(self.closePath) + self.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred, closing=True) + elif c is False: + self.collapseBtn.setPath(self.openPath) + self.setSizePolicy(self._lastSizePolicy) + else: + raise TypeError("Invalid argument %r; must be bool." % c) + + for ch in self.children(): + if isinstance(ch, QtGui.QWidget) and ch is not self.collapseBtn: + ch.setVisible(not c) + + self._collapsed = c + self.sigCollapseChanged.emit(c) + + def setSizePolicy(self, *args, **kwds): + QtGui.QGroupBox.setSizePolicy(self, *args) + if kwds.pop('closing', False) is True: + self._lastSizePolicy = self.sizePolicy() + + def setHorizontalPolicy(self, *args): + QtGui.QGroupBox.setHorizontalPolicy(self, *args) + self._lastSizePolicy = self.sizePolicy() + + def setVerticalPolicy(self, *args): + QtGui.QGroupBox.setVerticalPolicy(self, *args) + self._lastSizePolicy = self.sizePolicy() + + def setTitle(self, title): + # Leave room for button + QtGui.QGroupBox.setTitle(self, " " + title) + + def widgetGroupInterface(self): + return (self.sigCollapseChanged, + GroupBox.collapsed, + GroupBox.setCollapsed, + True) diff --git a/pyqtgraph/widgets/HistogramLUTWidget.py b/pyqtgraph/widgets/HistogramLUTWidget.py index 9aec837c1e..5259900cdc 100644 --- a/pyqtgraph/widgets/HistogramLUTWidget.py +++ b/pyqtgraph/widgets/HistogramLUTWidget.py @@ -13,7 +13,7 @@ class HistogramLUTWidget(GraphicsView): def __init__(self, parent=None, *args, **kargs): - background = kargs.get('background', 'default') + background = kargs.pop('background', 'default') GraphicsView.__init__(self, parent, useOpenGL=False, background=background) self.item = HistogramLUTItem(*args, **kargs) self.setCentralItem(self.item) diff --git a/pyqtgraph/widgets/LayoutWidget.py b/pyqtgraph/widgets/LayoutWidget.py index 65d04d3fb0..6eb1c8b986 100644 --- a/pyqtgraph/widgets/LayoutWidget.py +++ b/pyqtgraph/widgets/LayoutWidget.py @@ -39,7 +39,7 @@ def addLabel(self, text=' ', row=None, col=None, rowspan=1, colspan=1, **kargs): Returns the created widget. """ text = QtGui.QLabel(text, **kargs) - self.addItem(text, row, col, rowspan, colspan) + self.addWidget(text, row, col, rowspan, colspan) return text def addLayout(self, row=None, col=None, rowspan=1, colspan=1, **kargs): @@ -49,7 +49,7 @@ def addLayout(self, row=None, col=None, rowspan=1, colspan=1, **kargs): Returns the created widget. """ layout = LayoutWidget(**kargs) - self.addItem(layout, row, col, rowspan, colspan) + self.addWidget(layout, row, col, rowspan, colspan) return layout def addWidget(self, item, row=None, col=None, rowspan=1, colspan=1): @@ -75,7 +75,7 @@ def addWidget(self, item, row=None, col=None, rowspan=1, colspan=1): def getWidget(self, row, col): """Return the widget in (*row*, *col*)""" - return self.row[row][col] + return self.rows[row][col] #def itemIndex(self, item): #for i in range(self.layout.count()): diff --git a/pyqtgraph/widgets/MatplotlibWidget.py b/pyqtgraph/widgets/MatplotlibWidget.py index 3049683987..c5b6c98018 100644 --- a/pyqtgraph/widgets/MatplotlibWidget.py +++ b/pyqtgraph/widgets/MatplotlibWidget.py @@ -1,8 +1,8 @@ -from ..Qt import QtGui, QtCore, USE_PYSIDE, USE_PYQT5 +from ..Qt import QtGui, QtCore, QT_LIB import matplotlib -if not USE_PYQT5: - if USE_PYSIDE: +if QT_LIB != 'PyQt5': + if QT_LIB == 'PySide': matplotlib.rcParams['backend.qt4']='PySide' from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas diff --git a/pyqtgraph/widgets/PathButton.py b/pyqtgraph/widgets/PathButton.py index 52c60e2013..ee2e0bca96 100644 --- a/pyqtgraph/widgets/PathButton.py +++ b/pyqtgraph/widgets/PathButton.py @@ -5,9 +5,11 @@ class PathButton(QtGui.QPushButton): - """Simple PushButton extension which paints a QPainterPath on its face""" - def __init__(self, parent=None, path=None, pen='default', brush=None, size=(30,30)): + """Simple PushButton extension that paints a QPainterPath centered on its face. + """ + def __init__(self, parent=None, path=None, pen='default', brush=None, size=(30,30), margin=7): QtGui.QPushButton.__init__(self, parent) + self.margin = margin self.path = None if pen == 'default': pen = 'k' @@ -19,7 +21,6 @@ def __init__(self, parent=None, path=None, pen='default', brush=None, size=(30,3 self.setFixedWidth(size[0]) self.setFixedHeight(size[1]) - def setBrush(self, brush): self.brush = fn.mkBrush(brush) @@ -32,7 +33,7 @@ def setPath(self, path): def paintEvent(self, ev): QtGui.QPushButton.paintEvent(self, ev) - margin = 7 + margin = self.margin geom = QtCore.QRectF(0, 0, self.width(), self.height()).adjusted(margin, margin, -margin, -margin) rect = self.path.boundingRect() scale = min(geom.width() / float(rect.width()), geom.height() / float(rect.height())) diff --git a/pyqtgraph/widgets/PlotWidget.py b/pyqtgraph/widgets/PlotWidget.py index 8711e1f6f0..610591fb57 100644 --- a/pyqtgraph/widgets/PlotWidget.py +++ b/pyqtgraph/widgets/PlotWidget.py @@ -24,6 +24,7 @@ class PlotWidget(GraphicsView): :func:`addItem `, :func:`removeItem `, :func:`clear `, + :func:`setAxisItems `, :func:`setXRange `, :func:`setYRange `, :func:`setRange `, @@ -43,7 +44,7 @@ class PlotWidget(GraphicsView): For all other methods, use :func:`getPlotItem `. """ - def __init__(self, parent=None, background='default', **kargs): + def __init__(self, parent=None, background='default', plotItem=None, **kargs): """When initializing PlotWidget, *parent* and *background* are passed to :func:`GraphicsWidget.__init__() ` and all others are passed @@ -51,11 +52,14 @@ def __init__(self, parent=None, background='default', **kargs): GraphicsView.__init__(self, parent, background=background) self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Expanding) self.enableMouse(False) - self.plotItem = PlotItem(**kargs) + if plotItem is None: + self.plotItem = PlotItem(**kargs) + else: + self.plotItem = plotItem self.setCentralItem(self.plotItem) ## Explicitly wrap methods from plotItem ## NOTE: If you change this list, update the documentation above as well. - for m in ['addItem', 'removeItem', 'autoRange', 'clear', 'setXRange', + for m in ['addItem', 'removeItem', 'autoRange', 'clear', 'setAxisItems', 'setXRange', 'setYRange', 'setRange', 'setAspectLocked', 'setMouseEnabled', 'setXLink', 'setYLink', 'enableAutoRange', 'disableAutoRange', 'setLimits', 'register', 'unregister', 'viewRect']: @@ -76,7 +80,7 @@ def __getattr__(self, attr): ## implicitly wrap methods from plotItem m = getattr(self.plotItem, attr) if hasattr(m, '__call__'): return m - raise NameError(attr) + raise AttributeError(attr) def viewRangeChanged(self, view, range): #self.emit(QtCore.SIGNAL('viewChanged'), *args) @@ -96,4 +100,4 @@ def getPlotItem(self): return self.plotItem - \ No newline at end of file + diff --git a/pyqtgraph/widgets/ProgressDialog.py b/pyqtgraph/widgets/ProgressDialog.py index 8c669be4b4..ae1826bb67 100644 --- a/pyqtgraph/widgets/ProgressDialog.py +++ b/pyqtgraph/widgets/ProgressDialog.py @@ -2,9 +2,14 @@ from ..Qt import QtGui, QtCore __all__ = ['ProgressDialog'] + + class ProgressDialog(QtGui.QProgressDialog): """ - Extends QProgressDialog for use in 'with' statements. + Extends QProgressDialog: + + * Adds context management so the dialog may be used in `with` statements + * Allows nesting multiple progress dialogs Example:: @@ -14,7 +19,10 @@ class ProgressDialog(QtGui.QProgressDialog): if dlg.wasCanceled(): raise Exception("Processing canceled by user") """ - def __init__(self, labelText, minimum=0, maximum=100, cancelText='Cancel', parent=None, wait=250, busyCursor=False, disable=False): + + allDialogs = [] + + def __init__(self, labelText, minimum=0, maximum=100, cancelText='Cancel', parent=None, wait=250, busyCursor=False, disable=False, nested=False): """ ============== ================================================================ **Arguments:** @@ -29,8 +37,18 @@ def __init__(self, labelText, minimum=0, maximum=100, cancelText='Cancel', paren and calls to wasCanceled() will always return False. If ProgressDialog is entered from a non-gui thread, it will always be disabled. + nested (bool) If True, then this progress bar will be displayed inside + any pre-existing progress dialogs that also allow nesting. ============== ================================================================ """ + # attributes used for nesting dialogs + self.nestedLayout = None + self._nestableWidgets = None + self._nestingReady = False + self._topDialog = None + self._subBars = [] + self.nested = nested + isGuiThread = QtCore.QThread.currentThread() == QtCore.QCoreApplication.instance().thread() self.disabled = disable or (not isGuiThread) if self.disabled: @@ -42,20 +60,34 @@ def __init__(self, labelText, minimum=0, maximum=100, cancelText='Cancel', paren noCancel = True self.busyCursor = busyCursor - + QtGui.QProgressDialog.__init__(self, labelText, cancelText, minimum, maximum, parent) - self.setMinimumDuration(wait) + + # If this will be a nested dialog, then we ignore the wait time + if nested is True and len(ProgressDialog.allDialogs) > 0: + self.setMinimumDuration(2**30) + else: + self.setMinimumDuration(wait) + self.setWindowModality(QtCore.Qt.WindowModal) self.setValue(self.minimum()) if noCancel: self.setCancelButton(None) - def __enter__(self): if self.disabled: return self if self.busyCursor: QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor)) + + if self.nested and len(ProgressDialog.allDialogs) > 0: + topDialog = ProgressDialog.allDialogs[0] + topDialog._addSubDialog(self) + self._topDialog = topDialog + topDialog.canceled.connect(self.cancel) + + ProgressDialog.allDialogs.append(self) + return self def __exit__(self, exType, exValue, exTrace): @@ -63,6 +95,12 @@ def __exit__(self, exType, exValue, exTrace): return if self.busyCursor: QtGui.QApplication.restoreOverrideCursor() + + if self._topDialog is not None: + self._topDialog._removeSubDialog(self) + + ProgressDialog.allDialogs.pop(-1) + self.setValue(self.maximum()) def __iadd__(self, val): @@ -72,6 +110,88 @@ def __iadd__(self, val): self.setValue(self.value()+val) return self + def _addSubDialog(self, dlg): + # insert widgets from another dialog into this one. + + # set a new layout and arrange children into it (if needed). + self._prepareNesting() + + bar, btn = dlg._extractWidgets() + + # where should we insert this widget? Find the first slot with a + # "removed" widget (that was left as a placeholder) + inserted = False + for i,bar2 in enumerate(self._subBars): + if bar2.hidden: + self._subBars.pop(i) + bar2.hide() + bar2.setParent(None) + self._subBars.insert(i, bar) + inserted = True + break + if not inserted: + self._subBars.append(bar) + + # reset the layout + while self.nestedLayout.count() > 0: + self.nestedLayout.takeAt(0) + for b in self._subBars: + self.nestedLayout.addWidget(b) + + def _removeSubDialog(self, dlg): + # don't remove the widget just yet; instead we hide it and leave it in + # as a placeholder. + bar, btn = dlg._extractWidgets() + bar.hide() + + def _prepareNesting(self): + # extract all child widgets and place into a new layout that we can add to + if self._nestingReady is False: + # top layout contains progress bars + cancel button at the bottom + self._topLayout = QtGui.QGridLayout() + self.setLayout(self._topLayout) + self._topLayout.setContentsMargins(0, 0, 0, 0) + + # A vbox to contain all progress bars + self.nestedVBox = QtGui.QWidget() + self._topLayout.addWidget(self.nestedVBox, 0, 0, 1, 2) + self.nestedLayout = QtGui.QVBoxLayout() + self.nestedVBox.setLayout(self.nestedLayout) + + # re-insert all widgets + bar, btn = self._extractWidgets() + self.nestedLayout.addWidget(bar) + self._subBars.append(bar) + self._topLayout.addWidget(btn, 1, 1, 1, 1) + self._topLayout.setColumnStretch(0, 100) + self._topLayout.setColumnStretch(1, 1) + self._topLayout.setRowStretch(0, 100) + self._topLayout.setRowStretch(1, 1) + + self._nestingReady = True + + def _extractWidgets(self): + # return: + # 1. a single widget containing the label and progress bar + # 2. the cancel button + + if self._nestableWidgets is None: + widgets = [ch for ch in self.children() if isinstance(ch, QtGui.QWidget)] + label = [ch for ch in self.children() if isinstance(ch, QtGui.QLabel)][0] + bar = [ch for ch in self.children() if isinstance(ch, QtGui.QProgressBar)][0] + btn = [ch for ch in self.children() if isinstance(ch, QtGui.QPushButton)][0] + + sw = ProgressWidget(label, bar) + + self._nestableWidgets = (sw, btn) + + return self._nestableWidgets + + def resizeEvent(self, ev): + if self._nestingReady: + # don't let progress dialog manage widgets anymore. + return + return QtGui.QProgressDialog.resizeEvent(self, ev) ## wrap all other functions to make sure they aren't being called from non-gui threads @@ -80,6 +200,11 @@ def setValue(self, val): return QtGui.QProgressDialog.setValue(self, val) + # Qt docs say this should happen automatically, but that doesn't seem + # to be the case. + if self.windowModality() == QtCore.Qt.WindowModal: + QtGui.QApplication.processEvents() + def setLabelText(self, val): if self.disabled: return @@ -109,4 +234,29 @@ def minimum(self): if self.disabled: return 0 return QtGui.QProgressDialog.minimum(self) + + +class ProgressWidget(QtGui.QWidget): + """Container for a label + progress bar that also allows its child widgets + to be hidden without changing size. + """ + def __init__(self, label, bar): + QtGui.QWidget.__init__(self) + self.hidden = False + self.layout = QtGui.QVBoxLayout() + self.setLayout(self.layout) + self.label = label + self.bar = bar + self.layout.addWidget(label) + self.layout.addWidget(bar) + + def eventFilter(self, obj, ev): + return ev.type() == QtCore.QEvent.Paint + + def hide(self): + # hide label and bar, but continue occupying the same space in the layout + for widget in (self.label, self.bar): + widget.installEventFilter(self) + widget.update() + self.hidden = True diff --git a/pyqtgraph/widgets/RawImageWidget.py b/pyqtgraph/widgets/RawImageWidget.py index 657701f937..ef1d7a3831 100644 --- a/pyqtgraph/widgets/RawImageWidget.py +++ b/pyqtgraph/widgets/RawImageWidget.py @@ -122,7 +122,7 @@ def paintGL(self): if not self.uploaded: self.uploadTexture() - glViewport(0, 0, self.width(), self.height()) + glViewport(0, 0, self.width() * self.devicePixelRatio(), self.height() * self.devicePixelRatio()) glEnable(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, self.texture) glColor4f(1,1,1,1) diff --git a/pyqtgraph/widgets/RemoteGraphicsView.py b/pyqtgraph/widgets/RemoteGraphicsView.py index 85f5556abe..9be1b5310b 100644 --- a/pyqtgraph/widgets/RemoteGraphicsView.py +++ b/pyqtgraph/widgets/RemoteGraphicsView.py @@ -1,5 +1,5 @@ -from ..Qt import QtGui, QtCore, USE_PYSIDE -if not USE_PYSIDE: +from ..Qt import QtGui, QtCore, QT_LIB +if QT_LIB in ['PyQt4', 'PyQt5']: import sip from .. import multiprocess as mp from .GraphicsView import GraphicsView @@ -8,6 +8,56 @@ import mmap, tempfile, ctypes, atexit, sys, random __all__ = ['RemoteGraphicsView'] + +class SerializableWheelEvent: + """ + Contains all information of a QWheelEvent, is serializable and can generate QWheelEvents. + + Methods have the functionality of their QWheelEvent equivalent. + """ + def __init__(self, _pos, _globalPos, _delta, _buttons, _modifiers, _orientation): + self._pos = _pos + self._globalPos = _globalPos + self._delta = _delta + self._buttons = _buttons + self._modifiers = _modifiers + self._orientation_vertical = _orientation == QtCore.Qt.Vertical + + def pos(self): + return self._pos + + def globalPos(self): + return self._globalPos + + def delta(self): + return self._delta + + def orientation(self): + if self._orientation_vertical: + return QtCore.Qt.Vertical + else: + return QtCore.Qt.Horizontal + + def angleDelta(self): + if self._orientation_vertical: + return QtCore.QPoint(0, self._delta) + else: + return QtCore.QPoint(self._delta, 0) + + def buttons(self): + return QtCore.Qt.MouseButtons(self._buttons) + + def modifiers(self): + return QtCore.Qt.KeyboardModifiers(self._modifiers) + + def toQWheelEvent(self): + """ + Generate QWheelEvent from SerializableWheelEvent. + """ + if QT_LIB in ['PyQt4', 'PySide']: + return QtGui.QWheelEvent(self.pos(), self.globalPos(), self.delta(), self.buttons(), self.modifiers(), self.orientation()) + else: + return QtGui.QWheelEvent(self.pos(), self.globalPos(), QtCore.QPoint(), self.angleDelta(), self.delta(), self.orientation(), self.buttons(), self.modifiers()) class RemoteGraphicsView(QtGui.QWidget): """ @@ -97,22 +147,34 @@ def paintEvent(self, ev): p.end() def mousePressEvent(self, ev): - self._view.mousePressEvent(int(ev.type()), ev.pos(), ev.globalPos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') + self._view.mousePressEvent(int(ev.type()), ev.pos(), ev.pos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') ev.accept() return QtGui.QWidget.mousePressEvent(self, ev) def mouseReleaseEvent(self, ev): - self._view.mouseReleaseEvent(int(ev.type()), ev.pos(), ev.globalPos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') + self._view.mouseReleaseEvent(int(ev.type()), ev.pos(), ev.pos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') ev.accept() return QtGui.QWidget.mouseReleaseEvent(self, ev) def mouseMoveEvent(self, ev): - self._view.mouseMoveEvent(int(ev.type()), ev.pos(), ev.globalPos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') + self._view.mouseMoveEvent(int(ev.type()), ev.pos(), ev.pos(), int(ev.button()), int(ev.buttons()), int(ev.modifiers()), _callSync='off') ev.accept() return QtGui.QWidget.mouseMoveEvent(self, ev) def wheelEvent(self, ev): - self._view.wheelEvent(ev.pos(), ev.globalPos(), ev.delta(), int(ev.buttons()), int(ev.modifiers()), int(ev.orientation()), _callSync='off') + delta = 0 + orientation = QtCore.Qt.Horizontal + if QT_LIB in ['PyQt4', 'PySide']: + delta = ev.delta() + orientation = ev.orientation() + else: + delta = ev.angleDelta().x() + if delta == 0: + orientation = QtCore.Qt.Vertical + delta = ev.angleDelta().y() + + serializableEvent = SerializableWheelEvent(ev.pos(), ev.pos(), delta, int(ev.buttons()), int(ev.modifiers()), orientation) + self._view.wheelEvent(serializableEvent, _callSync='off') ev.accept() return QtGui.QWidget.wheelEvent(self, ev) @@ -152,6 +214,7 @@ def __init__(self, *args, **kwds): else: self.shmFile = tempfile.NamedTemporaryFile(prefix='pyqtgraph_shmem_') self.shmFile.write(b'\x00' * (mmap.PAGESIZE+1)) + self.shmFile.flush() fd = self.shmFile.fileno() self.shm = mmap.mmap(fd, mmap.PAGESIZE, mmap.MAP_SHARED, mmap.PROT_WRITE) atexit.register(self.close) @@ -208,7 +271,7 @@ def renderView(self): self.shm.resize(size) ## render the scene directly to shared memory - if USE_PYSIDE: + if QT_LIB in ['PySide', 'PySide2']: ch = ctypes.c_char.from_buffer(self.shm, 0) #ch = ctypes.c_char_p(address) self.img = QtGui.QImage(ch, self.width(), self.height(), QtGui.QImage.Format_ARGB32) @@ -250,12 +313,9 @@ def mouseReleaseEvent(self, typ, pos, gpos, btn, btns, mods): btns = QtCore.Qt.MouseButtons(btns) mods = QtCore.Qt.KeyboardModifiers(mods) return GraphicsView.mouseReleaseEvent(self, QtGui.QMouseEvent(typ, pos, gpos, btn, btns, mods)) - - def wheelEvent(self, pos, gpos, d, btns, mods, ori): - btns = QtCore.Qt.MouseButtons(btns) - mods = QtCore.Qt.KeyboardModifiers(mods) - ori = (None, QtCore.Qt.Horizontal, QtCore.Qt.Vertical)[ori] - return GraphicsView.wheelEvent(self, QtGui.QWheelEvent(pos, gpos, d, btns, mods, ori)) + + def wheelEvent(self, ev): + return GraphicsView.wheelEvent(self, ev.toQWheelEvent()) def keyEvent(self, typ, mods, text, autorep, count): typ = QtCore.QEvent.Type(typ) diff --git a/pyqtgraph/widgets/ScatterPlotWidget.py b/pyqtgraph/widgets/ScatterPlotWidget.py index cca40e6559..08f6d02b8a 100644 --- a/pyqtgraph/widgets/ScatterPlotWidget.py +++ b/pyqtgraph/widgets/ScatterPlotWidget.py @@ -33,6 +33,8 @@ class ScatterPlotWidget(QtGui.QSplitter): specifying multiple criteria. 4) A PlotWidget for displaying the data. """ + sigScatterPlotClicked = QtCore.Signal(object, object) + def __init__(self, parent=None): QtGui.QSplitter.__init__(self, QtCore.Qt.Horizontal) self.ctrlPanel = QtGui.QSplitter(QtCore.Qt.Vertical) @@ -50,16 +52,23 @@ def __init__(self, parent=None): self.ctrlPanel.addWidget(self.ptree) self.addWidget(self.plot) - bg = fn.mkColor(getConfigOption('background')) - bg.setAlpha(150) - self.filterText = TextItem(border=getConfigOption('foreground'), color=bg) + fg = fn.mkColor(getConfigOption('foreground')) + fg.setAlpha(150) + self.filterText = TextItem(border=getConfigOption('foreground'), color=fg) self.filterText.setPos(60,20) self.filterText.setParentItem(self.plot.plotItem) self.data = None + self.indices = None self.mouseOverField = None self.scatterPlot = None + self.selectionScatter = None + self.selectedIndices = [] self.style = dict(pen=None, symbol='o') + self._visibleXY = None # currently plotted points + self._visibleData = None # currently plotted records + self._visibleIndices = None + self._indexMap = None self.fieldList.itemSelectionChanged.connect(self.fieldSelectionChanged) self.filter.sigFilterChanged.connect(self.filterChanged) @@ -81,16 +90,45 @@ def setFields(self, fields, mouseOverField=None): item = self.fieldList.addItem(item) self.filter.setFields(fields) self.colorMap.setFields(fields) - + + def setSelectedFields(self, *fields): + self.fieldList.itemSelectionChanged.disconnect(self.fieldSelectionChanged) + try: + self.fieldList.clearSelection() + for f in fields: + i = list(self.fields.keys()).index(f) + item = self.fieldList.item(i) + item.setSelected(True) + finally: + self.fieldList.itemSelectionChanged.connect(self.fieldSelectionChanged) + self.fieldSelectionChanged() + def setData(self, data): """ Set the data to be processed and displayed. Argument must be a numpy record array. """ self.data = data + self.indices = np.arange(len(data)) self.filtered = None + self.filteredIndices = None self.updatePlot() + def setSelectedIndices(self, inds): + """Mark the specified indices as selected. + + Must be a sequence of integers that index into the array given in setData(). + """ + self.selectedIndices = inds + self.updateSelected() + + def setSelectedPoints(self, points): + """Mark the specified points as selected. + + Must be a list of points as generated by the sigScatterPlotClicked signal. + """ + self.setSelectedIndices([pt.originalIndex for pt in points]) + def fieldSelectionChanged(self): sel = self.fieldList.selectedItems() if len(sel) > 2: @@ -112,15 +150,16 @@ def filterChanged(self, f): else: self.filterText.setText('\n'.join(desc)) self.filterText.setVisible(True) - def updatePlot(self): self.plot.clear() - if self.data is None: + if self.data is None or len(self.data) == 0: return if self.filtered is None: - self.filtered = self.filter.filterData(self.data) + mask = self.filter.generateMask(self.data) + self.filtered = self.data[mask] + self.filteredIndices = self.indices[mask] data = self.filtered if len(data) == 0: return @@ -175,12 +214,14 @@ def updatePlot(self): ## mask out any nan values mask = np.ones(len(xy[0]), dtype=bool) if xy[0].dtype.kind == 'f': - mask &= ~np.isnan(xy[0]) + mask &= np.isfinite(xy[0]) if xy[1] is not None and xy[1].dtype.kind == 'f': - mask &= ~np.isnan(xy[1]) + mask &= np.isfinite(xy[1]) xy[0] = xy[0][mask] style['symbolBrush'] = colors[mask] + data = data[mask] + indices = self.filteredIndices[mask] ## Scatter y-values for a histogram-like appearance if xy[1] is None: @@ -202,17 +243,44 @@ def updatePlot(self): if smax != 0: scatter *= 0.2 / smax xy[ax][keymask] += scatter - + + if self.scatterPlot is not None: try: self.scatterPlot.sigPointsClicked.disconnect(self.plotClicked) except: pass - self.scatterPlot = self.plot.plot(xy[0], xy[1], data=data[mask], **style) - self.scatterPlot.sigPointsClicked.connect(self.plotClicked) - - def plotClicked(self, plot, points): - pass + self._visibleXY = xy + self._visibleData = data + self._visibleIndices = indices + self._indexMap = None + self.scatterPlot = self.plot.plot(xy[0], xy[1], data=data, **style) + self.scatterPlot.sigPointsClicked.connect(self.plotClicked) + self.updateSelected() + def updateSelected(self): + if self._visibleXY is None: + return + # map from global index to visible index + indMap = self._getIndexMap() + inds = [indMap[i] for i in self.selectedIndices if i in indMap] + x,y = self._visibleXY[0][inds], self._visibleXY[1][inds] + + if self.selectionScatter is not None: + self.plot.plotItem.removeItem(self.selectionScatter) + if len(x) == 0: + return + self.selectionScatter = self.plot.plot(x, y, pen=None, symbol='s', symbolSize=12, symbolBrush=None, symbolPen='y') + def _getIndexMap(self): + # mapping from original data index to visible point index + if self._indexMap is None: + self._indexMap = {j:i for i,j in enumerate(self._visibleIndices)} + return self._indexMap + + def plotClicked(self, plot, points): + # Tag each point with its index into the original dataset + for pt in points: + pt.originalIndex = self._visibleIndices[pt.index()] + self.sigScatterPlotClicked.emit(self, points) diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index a863cd6032..496ea37a87 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -1,25 +1,31 @@ # -*- coding: utf-8 -*- -from ..Qt import QtGui, QtCore -from ..python2_3 import asUnicode -from ..SignalProxy import SignalProxy - -from .. import functions as fn from math import log from decimal import Decimal as D ## Use decimal to avoid accumulating floating-point errors -from decimal import * +import decimal import weakref +import re + +from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode, basestring +from ..SignalProxy import SignalProxy +from .. import functions as fn + __all__ = ['SpinBox'] + + class SpinBox(QtGui.QAbstractSpinBox): """ **Bases:** QtGui.QAbstractSpinBox - QSpinBox widget on steroids. Allows selection of numerical value, with extra features: + Extension of QSpinBox widget for selection of a numerical value. + Adds many extra features: - - SI prefix notation (eg, automatically display "300 mV" instead of "0.003 V") - - Float values with linear and decimal stepping (1-9, 10-90, 100-900, etc.) - - Option for unbounded values - - Delayed signals (allows multiple rapid changes with only one change signal) + * SI prefix notation (eg, automatically display "300 mV" instead of "0.003 V") + * Float values with linear and decimal stepping (1-9, 10-90, 100-900, etc.) + * Option for unbounded values + * Delayed signals (allows multiple rapid changes with only one change signal) + * Customizable text formatting ============================= ============================================== **Signals:** @@ -42,67 +48,39 @@ class SpinBox(QtGui.QAbstractSpinBox): valueChanged = QtCore.Signal(object) # (value) for compatibility with QSpinBox sigValueChanged = QtCore.Signal(object) # (self) sigValueChanging = QtCore.Signal(object, object) # (self, value) sent immediately; no delay. - + def __init__(self, parent=None, value=0.0, **kwargs): """ ============== ======================================================================== **Arguments:** parent Sets the parent widget for this SpinBox (optional). Default is None. value (float/int) initial value. Default is 0.0. - bounds (min,max) Minimum and maximum values allowed in the SpinBox. - Either may be None to leave the value unbounded. By default, values are unbounded. - suffix (str) suffix (units) to display after the numerical value. By default, suffix is an empty str. - siPrefix (bool) If True, then an SI prefix is automatically prepended - to the units and the value is scaled accordingly. For example, - if value=0.003 and suffix='V', then the SpinBox will display - "300 mV" (but a call to SpinBox.value will still return 0.003). Default is False. - step (float) The size of a single step. This is used when clicking the up/ - down arrows, when rolling the mouse wheel, or when pressing - keyboard arrows while the widget has keyboard focus. Note that - the interpretation of this value is different when specifying - the 'dec' argument. Default is 0.01. - dec (bool) If True, then the step value will be adjusted to match - the current size of the variable (for example, a value of 15 - might step in increments of 1 whereas a value of 1500 would - step in increments of 100). In this case, the 'step' argument - is interpreted *relative* to the current value. The most common - 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is False. - minStep (float) When dec=True, this specifies the minimum allowable step size. - int (bool) if True, the value is forced to integer type. Default is False - decimals (int) Number of decimal values to display. Default is 2. ============== ======================================================================== + + All keyword arguments are passed to :func:`setOpts`. """ QtGui.QAbstractSpinBox.__init__(self, parent) self.lastValEmitted = None self.lastText = '' self.textValid = True ## If false, we draw a red border self.setMinimumWidth(0) - self.setMaximumHeight(20) + self._lastFontHeight = None + self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) + self.errorBox = ErrorBox(self.lineEdit()) + self.opts = { 'bounds': [None, None], - - ## Log scaling options #### Log mode is no longer supported. - #'step': 0.1, - #'minStep': 0.001, - #'log': True, - #'dec': False, - - ## decimal scaling option - example - #'step': 0.1, - #'minStep': .001, - #'log': False, - #'dec': True, + 'wrapping': False, ## normal arithmetic step 'step': D('0.01'), ## if 'dec' is false, the spinBox steps by 'step' every time ## if 'dec' is True, the step size is relative to the value ## 'step' needs to be an integral divisor of ten, ie 'step'*n=10 for some integer value of n (but only if dec is True) - 'log': False, + 'log': False, # deprecated 'dec': False, ## if true, does decimal stepping. ie from 1-10 it steps by 'step', from 10 to 100 it steps by 10*'step', etc. ## if true, minStep must be set in order to cross zero. - 'int': False, ## Set True to force value to be integer 'suffix': '', @@ -112,8 +90,13 @@ def __init__(self, parent=None, value=0.0, **kwargs): 'delayUntilEditFinished': True, ## do not send signals until text editing has finished - 'decimals': 3, + 'decimals': 6, + + 'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"), + 'regex': fn.FLOAT_REGEX, + 'evalFunc': D, + 'compactHeight': True, # manually remove extra margin outside of text } self.decOpts = ['step', 'minStep'] @@ -123,39 +106,94 @@ def __init__(self, parent=None, value=0.0, **kwargs): self.skipValidate = False self.setCorrectionMode(self.CorrectToPreviousValue) self.setKeyboardTracking(False) + self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay']) self.setOpts(**kwargs) + self._updateHeight() self.editingFinished.connect(self.editingFinishedEvent) - self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay']) - + def event(self, ev): ret = QtGui.QAbstractSpinBox.event(self, ev) if ev.type() == QtCore.QEvent.KeyPress and ev.key() == QtCore.Qt.Key_Return: ret = True ## For some reason, spinbox pretends to ignore return key press return ret - ##lots of config options, just gonna stuff 'em all in here rather than do the get/set crap. def setOpts(self, **opts): - """ - Changes the behavior of the SpinBox. Accepts most of the arguments - allowed in :func:`__init__ `. + """Set options affecting the behavior of the SpinBox. + ============== ======================================================================== + **Arguments:** + bounds (min,max) Minimum and maximum values allowed in the SpinBox. + Either may be None to leave the value unbounded. By default, values are + unbounded. + suffix (str) suffix (units) to display after the numerical value. By default, + suffix is an empty str. + siPrefix (bool) If True, then an SI prefix is automatically prepended + to the units and the value is scaled accordingly. For example, + if value=0.003 and suffix='V', then the SpinBox will display + "300 mV" (but a call to SpinBox.value will still return 0.003). Default + is False. + step (float) The size of a single step. This is used when clicking the up/ + down arrows, when rolling the mouse wheel, or when pressing + keyboard arrows while the widget has keyboard focus. Note that + the interpretation of this value is different when specifying + the 'dec' argument. Default is 0.01. + dec (bool) If True, then the step value will be adjusted to match + the current size of the variable (for example, a value of 15 + might step in increments of 1 whereas a value of 1500 would + step in increments of 100). In this case, the 'step' argument + is interpreted *relative* to the current value. The most common + 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is + False. + minStep (float) When dec=True, this specifies the minimum allowable step size. + int (bool) if True, the value is forced to integer type. Default is False + wrapping (bool) If True and both bounds are not None, spin box has circular behavior. + decimals (int) Number of decimal values to display. Default is 6. + format (str) Formatting string used to generate the text shown. Formatting is + done with ``str.format()`` and makes use of several arguments: + + * *value* - the unscaled value of the spin box + * *suffix* - the suffix string + * *scaledValue* - the scaled value to use when an SI prefix is present + * *siPrefix* - the SI prefix string (if any), or an empty string if + this feature has been disabled + * *suffixGap* - a single space if a suffix is present, or an empty + string otherwise. + regex (str or RegexObject) Regular expression used to parse the spinbox text. + May contain the following group names: + + * *number* - matches the numerical portion of the string (mandatory) + * *siPrefix* - matches the SI prefix string + * *suffix* - matches the suffix string + + Default is defined in ``pyqtgraph.functions.FLOAT_REGEX``. + evalFunc (callable) Fucntion that converts a numerical string to a number, + preferrably a Decimal instance. This function handles only the numerical + of the text; it does not have access to the suffix or SI prefix. + compactHeight (bool) if True, then set the maximum height of the spinbox based on the + height of its font. This allows more compact packing on platforms with + excessive widget decoration. Default is True. + ============== ======================================================================== """ #print opts - for k in opts: + for k,v in opts.items(): if k == 'bounds': - self.setMinimum(opts[k][0], update=False) - self.setMaximum(opts[k][1], update=False) + self.setMinimum(v[0], update=False) + self.setMaximum(v[1], update=False) elif k == 'min': - self.setMinimum(opts[k], update=False) + self.setMinimum(v, update=False) elif k == 'max': - self.setMaximum(opts[k], update=False) + self.setMaximum(v, update=False) elif k in ['step', 'minStep']: - self.opts[k] = D(asUnicode(opts[k])) + self.opts[k] = D(asUnicode(v)) elif k == 'value': pass ## don't set value until bounds have been set + elif k == 'format': + self.opts[k] = asUnicode(v) + elif k == 'regex' and isinstance(v, basestring): + self.opts[k] = re.compile(v) elif k in self.opts: - self.opts[k] = opts[k] + self.opts[k] = v else: raise TypeError("Invalid keyword argument '%s'." % k) if 'value' in opts: @@ -205,6 +243,16 @@ def setMinimum(self, m, update=True): self.opts['bounds'][0] = m if update: self.setValue() + + def wrapping(self): + """Return whether or not the spin box is circular.""" + return self.opts['wrapping'] + + def setWrapping(self, s): + """Set whether spin box is circular. + + Both bounds must be set for this to have an effect.""" + self.opts['wrapping'] = s def setPrefix(self, p): """Set a string prefix. @@ -248,14 +296,15 @@ def selectNumber(self): """ le = self.lineEdit() text = asUnicode(le.text()) - if self.opts['suffix'] == '': - le.setSelection(0, len(text)) - else: - try: - index = text.index(' ') - except ValueError: - return - le.setSelection(0, index) + m = self.opts['regex'].match(text) + if m is None: + return + s,e = m.start('number'), m.end('number') + le.setSelection(s, e-s) + + def focusInEvent(self, ev): + super(SpinBox, self).focusInEvent(ev) + self.selectNumber() def value(self): """ @@ -268,29 +317,39 @@ def value(self): return float(self.val) def setValue(self, value=None, update=True, delaySignal=False): - """ - Set the value of this spin. - If the value is out of bounds, it will be clipped to the nearest boundary. + """Set the value of this SpinBox. + + If the value is out of bounds, it will be clipped to the nearest boundary + or wrapped if wrapping is enabled. + If the spin is integer type, the value will be coerced to int. Returns the actual value set. If value is None, then the current value is used (this is for resetting the value after bounds, etc. have changed) """ - if value is None: value = self.value() bounds = self.opts['bounds'] - if bounds[0] is not None and value < bounds[0]: - value = bounds[0] - if bounds[1] is not None and value > bounds[1]: - value = bounds[1] + + if None not in bounds and self.opts['wrapping'] is True: + # Casting of Decimals to floats required to avoid unexpected behavior of remainder operator + value = float(value) + l, u = float(bounds[0]), float(bounds[1]) + value = (value - l) % (u - l) + l + else: + if bounds[0] is not None and value < bounds[0]: + value = bounds[0] + if bounds[1] is not None and value > bounds[1]: + value = bounds[1] if self.opts['int']: value = int(value) - value = D(asUnicode(value)) + if not isinstance(value, D): + value = D(asUnicode(value)) + if value == self.val: return prev = self.val @@ -304,7 +363,6 @@ def setValue(self, value=None, update=True, delaySignal=False): self.emitChanged() return value - def emitChanged(self): self.lastValEmitted = self.val @@ -324,13 +382,9 @@ def widgetGroupInterface(self): def sizeHint(self): return QtCore.QSize(120, 0) - def stepEnabled(self): return self.StepUpEnabled | self.StepDownEnabled - #def fixup(self, *args): - #print "fixup:", args - def stepBy(self, n): n = D(int(n)) ## n must be integral number of steps. s = [D(-1), D(1)][n >= 0] ## determine sign of step @@ -352,7 +406,7 @@ def stepBy(self, n): vs = [D(-1), D(1)][val >= 0] #exp = D(int(abs(val*(D('1.01')**(s*vs))).log10())) fudge = D('1.01')**(s*vs) ## fudge factor. at some places, the step size depends on the step sign. - exp = abs(val * fudge).log10().quantize(1, ROUND_FLOOR) + exp = abs(val * fudge).log10().quantize(1, decimal.ROUND_FLOOR) step = self.opts['step'] * D(10)**exp if 'minStep' in self.opts: step = max(step, self.opts['minStep']) @@ -364,7 +418,6 @@ def stepBy(self, n): if 'minStep' in self.opts and abs(val) < self.opts['minStep']: val = D(0) self.setValue(val, delaySignal=True) ## note all steps (arrow buttons, wheel, up/down keys..) emit delayed signals only. - def valueInRange(self, value): bounds = self.opts['bounds'] @@ -378,61 +431,62 @@ def valueInRange(self, value): return True def updateText(self, prev=None): - # get the number of decimal places to print - decimals = self.opts.get('decimals') - # temporarily disable validation self.skipValidate = True + + txt = self.formatText(prev=prev) + + # actually set the text + self.lineEdit().setText(txt) + self.lastText = txt + + # re-enable the validation + self.skipValidate = False + + def formatText(self, prev=None): + # get the number of decimal places to print + decimals = self.opts['decimals'] + suffix = self.opts['suffix'] - # add a prefix to the units if requested - if self.opts['siPrefix']: + # format the string + val = self.value() + if self.opts['siPrefix'] is True and len(self.opts['suffix']) > 0: + # SI prefix was requested, so scale the value accordingly - # special case: if it's zero use the previous prefix if self.val == 0 and prev is not None: + # special case: if it's zero use the previous prefix (s, p) = fn.siScale(prev) - - # NOTE: insert optional format string here? - txt = ("%."+str(decimals)+"g %s%s") % (0, p, self.opts['suffix']) else: - # NOTE: insert optional format string here as an argument? - txt = fn.siFormat(float(self.val), precision=decimals, suffix=self.opts['suffix']) + (s, p) = fn.siScale(val) + parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': p, 'scaledValue': s*val} - # otherwise, format the string manually else: - # NOTE: insert optional format string here? - txt = ('%.'+str(decimals)+'g%s') % (self.val , self.opts['suffix']) + # no SI prefix /suffix requested; scale is 1 + parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': '', 'scaledValue': val} - # actually set the text - self.lineEdit().setText(txt) - self.lastText = txt + parts['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' ' + + return self.opts['format'].format(**parts) - # re-enable the validation - self.skipValidate = False - def validate(self, strn, pos): if self.skipValidate: ret = QtGui.QValidator.Acceptable else: try: - ## first make sure we didn't mess with the suffix - suff = self.opts.get('suffix', '') - if len(suff) > 0 and asUnicode(strn)[-len(suff):] != suff: - ret = QtGui.QValidator.Invalid - - ## next see if we actually have an interpretable value + val = self.interpret() + if val is False: + ret = QtGui.QValidator.Intermediate else: - val = self.interpret() - if val is False: - ret = QtGui.QValidator.Intermediate + if self.valueInRange(val): + if not self.opts['delayUntilEditFinished']: + self.setValue(val, update=False) + ret = QtGui.QValidator.Acceptable else: - if self.valueInRange(val): - if not self.opts['delayUntilEditFinished']: - self.setValue(val, update=False) - ret = QtGui.QValidator.Acceptable - else: - ret = QtGui.QValidator.Intermediate + ret = QtGui.QValidator.Intermediate except: + import sys + sys.excepthook(*sys.exc_info()) ret = QtGui.QValidator.Intermediate ## draw / clear border @@ -444,42 +498,54 @@ def validate(self, strn, pos): ## since the text will be forced to its previous state anyway self.update() + self.errorBox.setVisible(not self.textValid) + ## support 2 different pyqt APIs. Bleh. if hasattr(QtCore, 'QString'): return (ret, pos) else: return (ret, strn, pos) - def paintEvent(self, ev): - QtGui.QAbstractSpinBox.paintEvent(self, ev) - - ## draw red border if text is invalid - if not self.textValid: - p = QtGui.QPainter(self) - p.setRenderHint(p.Antialiasing) - p.setPen(fn.mkPen((200,50,50), width=2)) - p.drawRoundedRect(self.rect().adjusted(2, 2, -2, -2), 4, 4) - p.end() + def fixup(self, strn): + # fixup is called when the spinbox loses focus with an invalid or intermediate string + self.updateText() + # support both PyQt APIs (for Python 2 and 3 respectively) + # http://pyqt.sourceforge.net/Docs/PyQt4/python_v3.html#qvalidator + try: + strn.clear() + strn.append(self.lineEdit().text()) + except AttributeError: + return self.lineEdit().text() def interpret(self): - """Return value of text. Return False if text is invalid, raise exception if text is intermediate""" + """Return value of text or False if text is invalid.""" strn = self.lineEdit().text() - suf = self.opts['suffix'] - if len(suf) > 0: - if strn[-len(suf):] != suf: - return False - #raise Exception("Units are invalid.") - strn = strn[:-len(suf)] + + # tokenize into numerical value, si prefix, and suffix try: - val = fn.siEval(strn) - except: - #sys.excepthook(*sys.exc_info()) - #print "invalid" + val, siprefix, suffix = fn.siParse(strn, self.opts['regex'], suffix=self.opts['suffix']) + except Exception: + return False + + # check suffix + if suffix != self.opts['suffix'] or (suffix == '' and siprefix != ''): return False - #print val + + # generate value + val = self.opts['evalFunc'](val) + if self.opts['int']: + val = int(fn.siApply(val, siprefix)) + else: + try: + val = fn.siApply(val, siprefix) + except Exception: + import sys + sys.excepthook(*sys.exc_info()) + return False + return val - + def editingFinishedEvent(self): """Edit has finished; set value.""" #print "Edit finished." @@ -488,7 +554,7 @@ def editingFinishedEvent(self): return try: val = self.interpret() - except: + except Exception: return if val is False: @@ -498,3 +564,44 @@ def editingFinishedEvent(self): #print "no value change:", val, self.val return self.setValue(val, delaySignal=False) ## allow text update so that values are reformatted pretty-like + + def _updateHeight(self): + # SpinBox has very large margins on some platforms; this is a hack to remove those + # margins and allow more compact packing of controls. + if not self.opts['compactHeight']: + self.setMaximumHeight(1e6) + return + h = QtGui.QFontMetrics(self.font()).height() + if self._lastFontHeight != h: + self._lastFontHeight = h + self.setMaximumHeight(h) + + def paintEvent(self, ev): + self._updateHeight() + QtGui.QAbstractSpinBox.paintEvent(self, ev) + + +class ErrorBox(QtGui.QWidget): + """Red outline to draw around lineedit when value is invalid. + (for some reason, setting border from stylesheet does not work) + """ + def __init__(self, parent): + QtGui.QWidget.__init__(self, parent) + parent.installEventFilter(self) + self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents) + self._resize() + self.setVisible(False) + + def eventFilter(self, obj, ev): + if ev.type() == QtCore.QEvent.Resize: + self._resize() + return False + + def _resize(self): + self.setGeometry(0, 0, self.parent().width(), self.parent().height()) + + def paintEvent(self, ev): + p = QtGui.QPainter(self) + p.setPen(fn.mkPen(color='r', width=2)) + p.drawRect(self.rect()) + p.end() diff --git a/pyqtgraph/widgets/TableWidget.py b/pyqtgraph/widgets/TableWidget.py index 57852864e5..0378b5fc34 100644 --- a/pyqtgraph/widgets/TableWidget.py +++ b/pyqtgraph/widgets/TableWidget.py @@ -146,7 +146,8 @@ def appendData(self, data): i += 1 self.setRow(i, [x for x in fn1(row)]) - if self._sorting and self.horizontalHeader().sortIndicatorSection() >= self.columnCount(): + if (self._sorting and self.horizontalHeadersSet and + self.horizontalHeader().sortIndicatorSection() >= self.columnCount()): self.sortByColumn(0, QtCore.Qt.AscendingOrder) def setEditable(self, editable=True): @@ -216,6 +217,8 @@ def iteratorFn(self, data): return self.iterate, list(map(asUnicode, data.dtype.names)) elif data is None: return (None,None) + elif np.isscalar(data): + return self.iterateScalar, None else: msg = "Don't know how to iterate over data type: {!s}".format(type(data)) raise TypeError(msg) @@ -230,6 +233,9 @@ def iterate(self, data): for x in data: yield x + def iterateScalar(self, data): + yield data + def appendRow(self, data): self.appendData([data]) @@ -345,9 +351,12 @@ def saveAll(self): def save(self, data): fileName = QtGui.QFileDialog.getSaveFileName(self, "Save As..", "", "Tab-separated values (*.tsv)") + if isinstance(fileName, tuple): + fileName = fileName[0] # Qt4/5 API difference if fileName == '': return - open(fileName, 'w').write(data) + with open(fileName, 'w') as fd: + fd.write(data) def contextMenuEvent(self, ev): self.contextMenu.popup(ev.globalPos()) diff --git a/pyqtgraph/widgets/TreeWidget.py b/pyqtgraph/widgets/TreeWidget.py index b98da6fa99..8c55ae2f9b 100644 --- a/pyqtgraph/widgets/TreeWidget.py +++ b/pyqtgraph/widgets/TreeWidget.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- -from weakref import * from ..Qt import QtGui, QtCore -from ..python2_3 import xrange - +from weakref import * __all__ = ['TreeWidget', 'TreeWidgetItem'] @@ -13,15 +11,23 @@ class TreeWidget(QtGui.QTreeWidget): This class demonstrates the absurd lengths one must go to to make drag/drop work.""" sigItemMoved = QtCore.Signal(object, object, object) # (item, parent, index) + sigItemCheckStateChanged = QtCore.Signal(object, object) + sigItemTextChanged = QtCore.Signal(object, object) + sigColumnCountChanged = QtCore.Signal(object, object) # self, count def __init__(self, parent=None): QtGui.QTreeWidget.__init__(self, parent) - #self.itemWidgets = WeakKeyDictionary() + + # wrap this item so that we can propagate tree change information + # to children. + self._invRootItem = InvisibleRootItem(QtGui.QTreeWidget.invisibleRootItem(self)) + self.setAcceptDrops(True) self.setDragEnabled(True) self.setEditTriggers(QtGui.QAbstractItemView.EditKeyPressed|QtGui.QAbstractItemView.SelectedClicked) self.placeholders = [] self.childNestingLimit = None + self.itemClicked.connect(self._itemClicked) def setItemWidget(self, item, col, wid): """ @@ -42,7 +48,7 @@ def setItemWidget(self, item, col, wid): def itemWidget(self, item, col): w = QtGui.QTreeWidget.itemWidget(self, item, col) - if w is not None: + if w is not None and hasattr(w, 'realChild'): w = w.realChild return w @@ -141,7 +147,6 @@ def dropEvent(self, ev): QtGui.QTreeWidget.dropEvent(self, ev) self.updateDropFlags() - def updateDropFlags(self): ### intended to put a limit on how deep nests of children can go. ### self.childNestingLimit is upheld when moving items without children, but if the item being moved has children/grandchildren, the children/grandchildren @@ -165,10 +170,8 @@ def updateDropFlags(self): def informTreeWidgetChange(item): if hasattr(item, 'treeWidgetChanged'): item.treeWidgetChanged() - else: - for i in xrange(item.childCount()): - TreeWidget.informTreeWidgetChange(item.child(i)) - + for i in range(item.childCount()): + TreeWidget.informTreeWidgetChange(item.child(i)) def addTopLevelItem(self, item): QtGui.QTreeWidget.addTopLevelItem(self, item) @@ -198,7 +201,7 @@ def takeTopLevelItem(self, index): return item def topLevelItems(self): - return map(self.topLevelItem, xrange(self.topLevelItemCount())) + return [self.topLevelItem(i) for i in range(self.topLevelItemCount())] def clear(self): items = self.topLevelItems() @@ -209,21 +212,59 @@ def clear(self): ## Why do we want to do this? It causes RuntimeErrors. #for item in items: #self.informTreeWidgetChange(item) + + def invisibleRootItem(self): + return self._invRootItem - + def itemFromIndex(self, index): + """Return the item and column corresponding to a QModelIndex. + """ + col = index.column() + rows = [] + while index.row() >= 0: + rows.insert(0, index.row()) + index = index.parent() + item = self.topLevelItem(rows[0]) + for row in rows[1:]: + item = item.child(row) + return item, col + + def setColumnCount(self, c): + QtGui.QTreeWidget.setColumnCount(self, c) + self.sigColumnCountChanged.emit(self, c) + + def _itemClicked(self, item, col): + if hasattr(item, 'itemClicked'): + item.itemClicked(col) + + class TreeWidgetItem(QtGui.QTreeWidgetItem): """ - TreeWidgetItem that keeps track of its own widgets. - Widgets may be added to columns before the item is added to a tree. + TreeWidgetItem that keeps track of its own widgets and expansion state. + + * Widgets may be added to columns before the item is added to a tree. + * Expanded state may be set before item is added to a tree. + * Adds setCheked and isChecked methods. + * Adds addChildren, insertChildren, and takeChildren methods. """ def __init__(self, *args): QtGui.QTreeWidgetItem.__init__(self, *args) self._widgets = {} # col: widget self._tree = None - + self._expanded = False def setChecked(self, column, checked): self.setCheckState(column, QtCore.Qt.Checked if checked else QtCore.Qt.Unchecked) + + def isChecked(self, col): + return self.checkState(col) == QtCore.Qt.Checked + + def setExpanded(self, exp): + self._expanded = exp + QtGui.QTreeWidgetItem.setExpanded(self, exp) + + def isExpanded(self): + return self._expanded def setWidget(self, column, widget): if column in self._widgets: @@ -251,7 +292,11 @@ def treeWidgetChanged(self): return for col, widget in self._widgets.items(): tree.setItemWidget(self, col, widget) - + QtGui.QTreeWidgetItem.setExpanded(self, self._expanded) + + def childItems(self): + return [self.child(i) for i in range(self.childCount())] + def addChild(self, child): QtGui.QTreeWidgetItem.addChild(self, child) TreeWidget.informTreeWidgetChange(child) @@ -285,4 +330,67 @@ def takeChildren(self): TreeWidget.informTreeWidgetChange(child) return childs + def setData(self, column, role, value): + # credit: ekhumoro + # http://stackoverflow.com/questions/13662020/how-to-implement-itemchecked-and-itemunchecked-signals-for-qtreewidget-in-pyqt4 + checkstate = self.checkState(column) + text = self.text(column) + QtGui.QTreeWidgetItem.setData(self, column, role, value) + + treewidget = self.treeWidget() + if treewidget is None: + return + if (role == QtCore.Qt.CheckStateRole and checkstate != self.checkState(column)): + treewidget.sigItemCheckStateChanged.emit(self, column) + elif (role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) and text != self.text(column)): + treewidget.sigItemTextChanged.emit(self, column) + + def itemClicked(self, col): + """Called when this item is clicked on. + + Override this method to react to user clicks. + """ + + +class InvisibleRootItem(object): + """Wrapper around a TreeWidget's invisible root item that calls + TreeWidget.informTreeWidgetChange when child items are added/removed. + """ + def __init__(self, item): + self._real_item = item + def addChild(self, child): + self._real_item.addChild(child) + TreeWidget.informTreeWidgetChange(child) + + def addChildren(self, childs): + self._real_item.addChildren(childs) + for child in childs: + TreeWidget.informTreeWidgetChange(child) + + def insertChild(self, index, child): + self._real_item.insertChild(index, child) + TreeWidget.informTreeWidgetChange(child) + + def insertChildren(self, index, childs): + self._real_item.addChildren(index, childs) + for child in childs: + TreeWidget.informTreeWidgetChange(child) + + def removeChild(self, child): + self._real_item.removeChild(child) + TreeWidget.informTreeWidgetChange(child) + + def takeChild(self, index): + child = self._real_item.takeChild(index) + TreeWidget.informTreeWidgetChange(child) + return child + + def takeChildren(self): + childs = self._real_item.takeChildren() + for child in childs: + TreeWidget.informTreeWidgetChange(child) + return childs + + def __getattr__(self, attr): + return getattr(self._real_item, attr) diff --git a/pyqtgraph/widgets/ValueLabel.py b/pyqtgraph/widgets/ValueLabel.py index 4e5b3011d2..b24fb16ccc 100644 --- a/pyqtgraph/widgets/ValueLabel.py +++ b/pyqtgraph/widgets/ValueLabel.py @@ -1,7 +1,6 @@ from ..Qt import QtCore, QtGui from ..ptime import time from .. import functions as fn -from functools import reduce __all__ = ['ValueLabel'] @@ -54,7 +53,7 @@ def setAverageTime(self, t): self.averageTime = t def averageValue(self): - return reduce(lambda a,b: a+b, [v[1] for v in self.values]) / float(len(self.values)) + return sum(v[1] for v in self.values) / float(len(self.values)) def paintEvent(self, ev): diff --git a/pyqtgraph/widgets/__init__.py b/pyqtgraph/widgets/__init__.py index a81fe391a1..e69de29bb2 100644 --- a/pyqtgraph/widgets/__init__.py +++ b/pyqtgraph/widgets/__init__.py @@ -1,21 +0,0 @@ -## just import everything from sub-modules - -#import os - -#d = os.path.split(__file__)[0] -#files = [] -#for f in os.listdir(d): - #if os.path.isdir(os.path.join(d, f)): - #files.append(f) - #elif f[-3:] == '.py' and f != '__init__.py': - #files.append(f[:-3]) - -#for modName in files: - #mod = __import__(modName, globals(), locals(), fromlist=['*']) - #if hasattr(mod, '__all__'): - #names = mod.__all__ - #else: - #names = [n for n in dir(mod) if n[0] != '_'] - #for k in names: - #print modName, k - #globals()[k] = getattr(mod, k) diff --git a/pyqtgraph/widgets/tests/test_histogramlutwidget.py b/pyqtgraph/widgets/tests/test_histogramlutwidget.py new file mode 100644 index 0000000000..f8a381a792 --- /dev/null +++ b/pyqtgraph/widgets/tests/test_histogramlutwidget.py @@ -0,0 +1,44 @@ +""" +HistogramLUTWidget test: + +Tests the creation of a HistogramLUTWidget. +""" + +import pyqtgraph as pg +from pyqtgraph.Qt import QtGui +import numpy as np + +def testHistogramLUTWidget(): + pg.mkQApp() + + win = QtGui.QMainWindow() + win.show() + + cw = QtGui.QWidget() + win.setCentralWidget(cw) + + l = QtGui.QGridLayout() + cw.setLayout(l) + l.setSpacing(0) + + v = pg.GraphicsView() + vb = pg.ViewBox() + vb.setAspectLocked() + v.setCentralItem(vb) + l.addWidget(v, 0, 0, 3, 1) + + w = pg.HistogramLUTWidget(background='w') + l.addWidget(w, 0, 1) + + data = pg.gaussianFilter(np.random.normal(size=(256, 256, 3)), (20, 20, 0)) + for i in range(32): + for j in range(32): + data[i*8, j*8] += .1 + img = pg.ImageItem(data) + vb.addItem(img) + vb.autoRange() + + w.setImageItem(img) + + QtGui.QApplication.processEvents() + diff --git a/pyqtgraph/widgets/tests/test_spinbox.py b/pyqtgraph/widgets/tests/test_spinbox.py new file mode 100644 index 0000000000..cff97da70e --- /dev/null +++ b/pyqtgraph/widgets/tests/test_spinbox.py @@ -0,0 +1,41 @@ +import pyqtgraph as pg +pg.mkQApp() + + +def test_spinbox_formatting(): + sb = pg.SpinBox() + assert sb.opts['decimals'] == 6 + assert sb.opts['int'] is False + + # table of test conditions: + # value, text, options + conds = [ + (0, '0', dict(suffix='', siPrefix=False, dec=False, int=False)), + (100, '100', dict()), + (1000000, '1e+06', dict()), + (1000, '1e+03', dict(decimals=2)), + (1000000, '1e+06', dict(int=True, decimals=6)), + (12345678955, '12345678955', dict(int=True, decimals=100)), + (1.45e-9, '1.45e-09 A', dict(int=False, decimals=6, suffix='A', siPrefix=False)), + (1.45e-9, '1.45 nA', dict(int=False, decimals=6, suffix='A', siPrefix=True)), + (1.45, '1.45 PSI', dict(int=False, decimals=6, suffix='PSI', siPrefix=True)), + (1.45e-3, '1.45 mPSI', dict(int=False, decimals=6, suffix='PSI', siPrefix=True)), + (-2500.3427, '$-2500.34', dict(int=False, format='${value:0.02f}')), + ] + + for (value, text, opts) in conds: + sb.setOpts(**opts) + sb.setValue(value) + assert sb.value() == value + assert pg.asUnicode(sb.text()) == text + + # test setting value + if not opts.get('int', False): + suf = sb.opts['suffix'] + sb.lineEdit().setText('0.1' + suf) + sb.editingFinishedEvent() + assert sb.value() == 0.1 + if suf != '': + sb.lineEdit().setText('0.1 m' + suf) + sb.editingFinishedEvent() + assert sb.value() == 0.1e-3 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..355e9dfda4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,19 @@ +[pytest] +xvfb_width = 1920 +xvfb_height = 1080 +# use this due to some issues with ndarray reshape errors on CI systems +xvfb_colordepth = 24 +xvfb_args=-ac +extension GLX +render +faulthandler_timeout = 15 + +filterwarnings = + # comfortable skipping these warnings runtime warnings + # https://stackoverflow.com/questions/40845304/runtimewarning-numpy-dtype-size-changed-may-indicate-binary-incompatibility + ignore:numpy.ufunc size changed, may indicate binary incompatibility.*:RuntimeWarning + # Warnings generated from PyQt5.9 + ignore:This method will be removed in future versions. Use 'tree.iter\(\)' or 'list\(tree.iter\(\)\)' instead.:PendingDeprecationWarning + ignore:.*'U' mode is deprecated.*:DeprecationWarning + # py36/pyside2_512 specific issue + ignore:split\(\) requires a non-empty pattern match\.:FutureWarning + # pyqtgraph specific warning we want to ignore during testing + ignore:Visible window deleted. To prevent this, store a reference to the window object. \ No newline at end of file diff --git a/setup.py b/setup.py index a59f7dd573..aa1bb7879d 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ +# -*- coding: utf-8 -*- DESCRIPTION = """\ -PyQtGraph is a pure-python graphics and GUI library built on PyQt4/PySide and +PyQtGraph is a pure-python graphics and GUI library built on PyQt4/PyQt5/PySide/PySide2 and numpy. It is intended for use in mathematics / scientific / engineering applications. @@ -12,14 +13,13 @@ name='pyqtgraph', description='Scientific Graphics and GUI Library for Python', long_description=DESCRIPTION, - license='MIT', + license = 'MIT', url='http://www.pyqtgraph.org', author='Luke Campagnola', author_email='luke.campagnola@gmail.com', classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Development Status :: 4 - Beta", @@ -141,8 +141,7 @@ def run(self): package_dir={'pyqtgraph.examples': 'examples'}, ## install examples along with the rest of the source package_data={'pyqtgraph.examples': ['optics/*.gz', 'relativity/presets/*.cfg']}, install_requires = [ - 'numpy', + 'numpy>=1.8.0', ], **setupOpts ) - diff --git a/test.py b/test.py new file mode 100644 index 0000000000..d2aeff5c6a --- /dev/null +++ b/test.py @@ -0,0 +1,24 @@ +""" +Script for invoking pytest with options to select Qt library +""" + +import sys +import pytest + +args = sys.argv[1:] +if '--pyside' in args: + args.remove('--pyside') + import PySide +elif '--pyqt4' in args: + args.remove('--pyqt4') + import PyQt4 +elif '--pyqt5' in args: + args.remove('--pyqt5') + import PyQt5 +elif '--pyside2' in args: + args.remove('--pyside2') + import PySide2 + +import pyqtgraph as pg +pg.systemInfo() +pytest.main(args) diff --git a/tools/pg-release.py b/tools/pg-release.py index ac32b19996..bc05f63865 100644 --- a/tools/pg-release.py +++ b/tools/pg-release.py @@ -77,7 +77,7 @@ def build(args): mkdir -p {build_dir} cd {build_dir} rm -rf pyqtgraph - git clone --depth 1 -b master {source_repo} pyqtgraph + git clone --depth 1 --branch master --single-branch {source_repo} pyqtgraph cd pyqtgraph git checkout -b release-{version} git pull {source_repo} release-{version} @@ -202,15 +202,19 @@ def publish(args): ### Upload everything to server shell(""" - # Uploading documentation.. cd {build_dir}/pyqtgraph - rsync -rv doc/build/* pyqtgraph.org:/www/code/pyqtgraph/pyqtgraph/documentation/build/ + + # Uploading documentation.. (disabled; now hosted by readthedocs.io) + #rsync -rv doc/build/* pyqtgraph.org:/www/code/pyqtgraph/pyqtgraph/documentation/build/ # Uploading release packages to website - rsync -v {pkg_dir}/{version} pyqtgraph.org:/www/code/pyqtgraph/downloads/ + rsync -v {pkg_dir} pyqtgraph.org:/www/code/pyqtgraph/downloads/ - # Push to github - git push --tags https://github.com/pyqtgraph/pyqtgraph master:master + # Push master to github + git push https://github.com/pyqtgraph/pyqtgraph master:master + + # Push tag to github + git push https://github.com/pyqtgraph/pyqtgraph pyqtgraph-{version} # Upload to pypi.. python setup.py sdist upload diff --git a/tools/rebuildUi.py b/tools/rebuildUi.py index 2ce80d878f..18de45d61a 100644 --- a/tools/rebuildUi.py +++ b/tools/rebuildUi.py @@ -1,3 +1,4 @@ +#!/usr/bin/python """ Script for compiling Qt Designer .ui files to .py @@ -9,18 +10,27 @@ pyqtuic = 'pyuic4' pysideuic = 'pyside-uic' pyqt5uic = 'pyuic5' +pyside2uic = 'pyside2-uic' usage = """Compile .ui files to .py for all supported pyqt/pyside versions. - Usage: python rebuildUi.py [.ui files|search paths] + Usage: python rebuildUi.py [--force] [.ui files|search paths] May specify a list of .ui files and/or directories to search recursively for .ui files. """ args = sys.argv[1:] + +if '--force' in args: + force = True + args.remove('--force') +else: + force = False + if len(args) == 0: print(usage) sys.exit(-1) + uifiles = [] for arg in args: @@ -40,9 +50,9 @@ # rebuild all requested ui files for ui in uifiles: base, _ = os.path.splitext(ui) - for compiler, ext in [(pyqtuic, '_pyqt.py'), (pysideuic, '_pyside.py'), (pyqt5uic, '_pyqt5.py')]: + for compiler, ext in [(pyqtuic, '_pyqt.py'), (pysideuic, '_pyside.py'), (pyqt5uic, '_pyqt5.py'), (pyside2uic, '_pyside2.py')]: py = base + ext - if os.path.exists(py) and os.stat(ui).st_mtime <= os.stat(py).st_mtime: + if not force and os.path.exists(py) and os.stat(ui).st_mtime <= os.stat(py).st_mtime: print("Skipping %s; already compiled." % py) else: cmd = '%s %s > %s' % (compiler, ui, py) diff --git a/tools/setupHelpers.py b/tools/setupHelpers.py index 939bca4e2f..6ebbfa46cf 100644 --- a/tools/setupHelpers.py +++ b/tools/setupHelpers.py @@ -10,14 +10,15 @@ def check_output(*args, **kwds): output = proc.stdout.read() proc.wait() if proc.returncode != 0: - ex = Exception("Process had nonzero return value %d" % proc.returncode) + ex = Exception("Process had nonzero return value " + + "%d " % proc.returncode) ex.returncode = proc.returncode ex.output = output raise ex return output # Maximum allowed repository size difference (in kB) following merge. -# This is used to prevent large files from being inappropriately added to +# This is used to prevent large files from being inappropriately added to # the repository history. MERGE_SIZE_LIMIT = 100 @@ -42,19 +43,19 @@ def check_output(*args, **kwds): 'E901', # SyntaxError or IndentationError 'E902', # IOError - + 'W191', # indentation contains tabs - + 'W601', # .has_key() is deprecated, use ‘in’ 'W602', # deprecated form of raising exception 'W603', # ‘<>’ is deprecated, use ‘!=’ - 'W604', # backticks are deprecated, use ‘repr()’ + 'W604', # backticks are deprecated, use ‘repr()’ ]) FLAKE_RECOMMENDED = set([ 'E124', # closing bracket does not match visual indentation 'E231', # missing whitespace after ‘,’ - + 'E211', # whitespace before ‘(‘ 'E261', # at least two spaces before inline comment 'E271', # multiple spaces after keyword @@ -65,10 +66,10 @@ def check_output(*args, **kwds): 'F402', # import module from line N shadowed by loop variable 'F403', # ‘from module import *’ used; unable to detect undefined names 'F404', # future import(s) name after other statements - + 'E501', # line too long (82 > 79 characters) 'E502', # the backslash is redundant between brackets - + 'E702', # multiple statements on one line (semicolon) 'E703', # statement ends with a semicolon 'E711', # comparison to None should be ‘if cond is None:’ @@ -82,7 +83,7 @@ def check_output(*args, **kwds): 'F823', # local variable name ... referenced before assignment 'F831', # duplicate argument name in function definition 'F841', # local variable name is assigned to but never used - + 'W292', # no newline at end of file ]) @@ -93,7 +94,7 @@ def check_output(*args, **kwds): 'E126', # continuation line over-indented for hanging indent 'E127', # continuation line over-indented for visual indent 'E128', # continuation line under-indented for visual indent - + 'E201', # whitespace after ‘(‘ 'E202', # whitespace before ‘)’ 'E203', # whitespace before ‘:’ @@ -105,19 +106,19 @@ def check_output(*args, **kwds): 'E228', # missing whitespace around modulo operator 'E241', # multiple spaces after ‘,’ 'E251', # unexpected spaces around keyword / parameter equals - 'E262', # inline comment should start with ‘# ‘ - + 'E262', # inline comment should start with ‘# ‘ + 'E301', # expected 1 blank line, found 0 'E302', # expected 2 blank lines, found 0 'E303', # too many blank lines (3) - + 'E401', # multiple imports on one line 'E701', # multiple statements on one line (colon) - + 'W291', # trailing whitespace 'W293', # blank line contains whitespace - + 'W391', # blank line at end of file ]) @@ -128,23 +129,10 @@ def check_output(*args, **kwds): ]) -#def checkStyle(): - #try: - #out = check_output(['flake8', '--select=%s' % FLAKE_TESTS, '--statistics', 'pyqtgraph/']) - #ret = 0 - #print("All style checks OK.") - #except Exception as e: - #out = e.output - #ret = e.returncode - #print(out.decode('utf-8')) - #return ret - - def checkStyle(): """ Run flake8, checking only lines that are modified since the last git commit. """ - test = [ 1,2,3 ] - + # First check _all_ code against mandatory error codes print('flake8: check all code against mandatory error set...') errors = ','.join(FLAKE_MANDATORY) @@ -154,39 +142,47 @@ def checkStyle(): output = proc.stdout.read().decode('utf-8') ret = proc.wait() printFlakeOutput(output) - + # Check for DOS newlines print('check line endings in all files...') count = 0 allowedEndings = set([None, '\n']) for path, dirs, files in os.walk('.'): + if path.startswith("." + os.path.sep + ".tox"): + continue for f in files: if os.path.splitext(f)[1] not in ('.py', '.rst'): continue filename = os.path.join(path, f) fh = open(filename, 'U') - x = fh.readlines() - endings = set(fh.newlines if isinstance(fh.newlines, tuple) else (fh.newlines,)) + _ = fh.readlines() + endings = set( + fh.newlines + if isinstance(fh.newlines, tuple) + else (fh.newlines,) + ) endings -= allowedEndings if len(endings) > 0: - print("\033[0;31m" + "File has invalid line endings: %s" % filename + "\033[0m") + print("\033[0;31m" + + "File has invalid line endings: " + + "%s" % filename + "\033[0m") ret = ret | 2 count += 1 print('checked line endings in %d files' % count) - - + + # Next check new code with optional error codes print('flake8: check new code against recommended error set...') diff = subprocess.check_output(['git', 'diff']) - proc = subprocess.Popen(['flake8', '--diff', #'--show-source', + proc = subprocess.Popen(['flake8', '--diff', # '--show-source', '--ignore=' + errors], - stdin=subprocess.PIPE, + stdin=subprocess.PIPE, stdout=subprocess.PIPE) proc.stdin.write(diff) proc.stdin.close() output = proc.stdout.read().decode('utf-8') ret |= printFlakeOutput(output) - + if ret == 0: print('style test passed.') else: @@ -244,14 +240,20 @@ def unitTests(): return ret -def checkMergeSize(sourceBranch=None, targetBranch=None, sourceRepo=None, targetRepo=None): +def checkMergeSize( + sourceBranch=None, + targetBranch=None, + sourceRepo=None, + targetRepo=None +): """ - Check that a git merge would not increase the repository size by MERGE_SIZE_LIMIT. + Check that a git merge would not increase the repository size by + MERGE_SIZE_LIMIT. """ if sourceBranch is None: sourceBranch = getGitBranch() sourceRepo = '..' - + if targetBranch is None: if sourceBranch == 'develop': targetBranch = 'develop' @@ -259,38 +261,38 @@ def checkMergeSize(sourceBranch=None, targetBranch=None, sourceRepo=None, target else: targetBranch = 'develop' targetRepo = '..' - + workingDir = '__merge-test-clone' - env = dict(TARGET_BRANCH=targetBranch, - SOURCE_BRANCH=sourceBranch, - TARGET_REPO=targetRepo, + env = dict(TARGET_BRANCH=targetBranch, + SOURCE_BRANCH=sourceBranch, + TARGET_REPO=targetRepo, SOURCE_REPO=sourceRepo, WORKING_DIR=workingDir, ) - + print("Testing merge size difference:\n" " SOURCE: {SOURCE_REPO} {SOURCE_BRANCH}\n" " TARGET: {TARGET_BRANCH} {TARGET_REPO}".format(**env)) - + setup = """ mkdir {WORKING_DIR} && cd {WORKING_DIR} && git init && git remote add -t {TARGET_BRANCH} target {TARGET_REPO} && - git fetch target {TARGET_BRANCH} && - git checkout -qf target/{TARGET_BRANCH} && + git fetch target {TARGET_BRANCH} && + git checkout -qf target/{TARGET_BRANCH} && git gc -q --aggressive """.format(**env) - + checkSize = """ - cd {WORKING_DIR} && + cd {WORKING_DIR} && du -s . | sed -e "s/\t.*//" """.format(**env) - + merge = """ cd {WORKING_DIR} && - git pull -q {SOURCE_REPO} {SOURCE_BRANCH} && + git pull -q {SOURCE_REPO} {SOURCE_BRANCH} && git gc -q --aggressive """.format(**env) - + try: print("Check out target branch:\n" + setup) check_call(setup, shell=True) @@ -300,13 +302,17 @@ def checkMergeSize(sourceBranch=None, targetBranch=None, sourceRepo=None, target check_call(merge, shell=True) mergeSize = int(check_output(checkSize, shell=True)) print("MERGE SIZE: %d kB" % mergeSize) - + diff = mergeSize - targetSize if diff <= MERGE_SIZE_LIMIT: print("DIFFERENCE: %d kB [OK]" % diff) return 0 else: - print("\033[0;31m" + "DIFFERENCE: %d kB [exceeds %d kB]" % (diff, MERGE_SIZE_LIMIT) + "\033[0m") + print("\033[0;31m" + + "DIFFERENCE: %d kB [exceeds %d kB]" % ( + diff, + MERGE_SIZE_LIMIT) + + "\033[0m") return 2 finally: if os.path.isdir(workingDir): @@ -327,7 +333,11 @@ def mergeTests(): def listAllPackages(pkgroot): path = os.getcwd() n = len(path.split(os.path.sep)) - subdirs = [i[0].split(os.path.sep)[n:] for i in os.walk(os.path.join(path, pkgroot)) if '__init__.py' in i[2]] + subdirs = [ + i[0].split(os.path.sep)[n:] + for i in os.walk(os.path.join(path, pkgroot)) + if '__init__.py' in i[2] + ] return ['.'.join(p) for p in subdirs] @@ -338,48 +348,61 @@ def getInitVersion(pkgroot): init = open(initfile).read() m = re.search(r'__version__ = (\S+)\n', init) if m is None or len(m.groups()) != 1: - raise Exception("Cannot determine __version__ from init file: '%s'!" % initfile) + raise Exception("Cannot determine __version__ from init file: " + + "'%s'!" % initfile) version = m.group(1).strip('\'\"') return version def gitCommit(name): """Return the commit ID for the given name.""" - commit = check_output(['git', 'show', name], universal_newlines=True).split('\n')[0] + commit = check_output( + ['git', 'show', name], + universal_newlines=True).split('\n')[0] assert commit[:7] == 'commit ' return commit[7:] def getGitVersion(tagPrefix): """Return a version string with information about this git checkout. - If the checkout is an unmodified, tagged commit, then return the tag version. - If this is not a tagged commit, return the output of ``git describe --tags``. + If the checkout is an unmodified, tagged commit, then return the tag + version + + If this is not a tagged commit, return the output of + ``git describe --tags`` + If this checkout has been modified, append "+" to the version. """ path = os.getcwd() if not os.path.isdir(os.path.join(path, '.git')): return None - - v = check_output(['git', 'describe', '--tags', '--dirty', '--match=%s*'%tagPrefix]).strip().decode('utf-8') - + + v = check_output(['git', + 'describe', + '--tags', + '--dirty', + '--match=%s*'%tagPrefix]).strip().decode('utf-8') + # chop off prefix assert v.startswith(tagPrefix) v = v[len(tagPrefix):] # split up version parts parts = v.split('-') - + # has working tree been modified? modified = False if parts[-1] == 'dirty': modified = True parts = parts[:-1] - + # have commits been added on top of last tagged version? # (git describe adds -NNN-gXXXXXXX if this is the case) local = None - if len(parts) > 2 and re.match(r'\d+', parts[-2]) and re.match(r'g[0-9a-f]{7}', parts[-1]): + if (len(parts) > 2 and + re.match(r'\d+', parts[-2]) and + re.match(r'g[0-9a-f]{7}', parts[-1])): local = parts[-1] parts = parts[:-2] - + gitVersion = '-'.join(parts) if local is not None: gitVersion += '+' + local @@ -389,7 +412,10 @@ def getGitVersion(tagPrefix): return gitVersion def getGitBranch(): - m = re.search(r'\* (.*)', check_output(['git', 'branch'], universal_newlines=True)) + m = re.search( + r'\* (.*)', + check_output(['git', 'branch'], + universal_newlines=True)) if m is None: return '' else: @@ -397,32 +423,33 @@ def getGitBranch(): def getVersionStrings(pkg): """ - Returns 4 version strings: - + Returns 4 version strings: + * the version string to use for this build, * version string requested with --force-version (or None) * version string that describes the current git checkout (or None). - * version string in the pkg/__init__.py, - + * version string in the pkg/__init__.py, + The first return value is (forceVersion or gitVersion or initVersion). """ - + ## Determine current version string from __init__.py initVersion = getInitVersion(pkgroot=pkg) - ## If this is a git checkout, try to generate a more descriptive version string + # If this is a git checkout + # try to generate a more descriptive version string try: gitVersion = getGitVersion(tagPrefix=pkg+'-') except: gitVersion = None - sys.stderr.write("This appears to be a git checkout, but an error occurred " - "while attempting to determine a version string for the " - "current commit.\n") + sys.stderr.write("This appears to be a git checkout, but an error " + "occurred while attempting to determine a version " + "string for the current commit.\n") sys.excepthook(*sys.exc_info()) # See whether a --force-version flag was given forcedVersion = None - for i,arg in enumerate(sys.argv): + for i, arg in enumerate(sys.argv): if arg.startswith('--force-version'): if arg == '--force-version': forcedVersion = sys.argv[i+1] @@ -431,16 +458,20 @@ def getVersionStrings(pkg): elif arg.startswith('--force-version='): forcedVersion = sys.argv[i].replace('--force-version=', '') sys.argv.pop(i) - - + + ## Finally decide on a version string to use: if forcedVersion is not None: version = forcedVersion - elif gitVersion is not None and getGitBranch() != 'debian': # ignore git version if this is debian branch - version = gitVersion - sys.stderr.write("Detected git commit; will use version string: '%s'\n" % version) else: version = initVersion + # if git says this is a modified branch, add local version information + if gitVersion is not None: + _, local = gitVersion.split('+') + if local != '': + version = version + '+' + local + sys.stderr.write("Detected git commit; " + + "will use version string: '%s'\n" % version) return version, forcedVersion, gitVersion, initVersion @@ -454,29 +485,31 @@ class DebCommand(Command): maintainer = "Luke Campagnola " debTemplate = "debian" debDir = "deb_build" - + user_options = [] - + def initialize_options(self): self.cwd = None - + def finalize_options(self): self.cwd = os.getcwd() - + def run(self): version = self.distribution.get_version() pkgName = self.distribution.get_name() debName = "python-" + pkgName debDir = self.debDir - - assert os.getcwd() == self.cwd, 'Must be in package root: %s' % self.cwd - + + assert os.getcwd() == self.cwd, 'Must be in package root: ' + + '%s' % self.cwd + if os.path.isdir(debDir): raise Exception('DEB build dir already exists: "%s"' % debDir) sdist = "dist/%s-%s.tar.gz" % (pkgName, version) if not os.path.isfile(sdist): - raise Exception("No source distribution; run `setup.py sdist` first.") - + raise Exception("No source distribution; " + + "run `setup.py sdist` first.") + # copy sdist to build directory and extract os.mkdir(debDir) renamedSdist = '%s_%s.orig.tar.gz' % (debName, version) @@ -486,16 +519,20 @@ def run(self): if os.system("cd %s; tar -xzf %s" % (debDir, renamedSdist)) != 0: raise Exception("Error extracting source distribution.") buildDir = '%s/%s-%s' % (debDir, pkgName, version) - + # copy debian control structure print("copytree %s => %s" % (self.debTemplate, buildDir+'/debian')) shutil.copytree(self.debTemplate, buildDir+'/debian') - + # Write new changelog - chlog = generateDebianChangelog(pkgName, 'CHANGELOG', version, self.maintainer) + chlog = generateDebianChangelog( + pkgName, + 'CHANGELOG', + version, + self.maintainer) print("write changelog %s" % buildDir+'/debian/changelog') open(buildDir+'/debian/changelog', 'w').write(chlog) - + # build package print('cd %s; debuild -us -uc' % buildDir) if os.system('cd %s; debuild -us -uc' % buildDir) != 0: @@ -518,43 +555,45 @@ def run(self): class TestCommand(Command): - description = "Run all package tests and exit immediately with informative return code." + description = "Run all package tests and exit immediately with ", \ + "informative return code." user_options = [] - + def run(self): sys.exit(unitTests()) - + def initialize_options(self): pass - + def finalize_options(self): pass - + class StyleCommand(Command): - description = "Check all code for style, exit immediately with informative return code." + description = "Check all code for style, exit immediately with ", \ + "informative return code." user_options = [] - + def run(self): sys.exit(checkStyle()) - + def initialize_options(self): pass - + def finalize_options(self): pass - + class MergeTestCommand(Command): - description = "Run all tests needed to determine whether the current code is suitable for merge." + description = "Run all tests needed to determine whether the current ",\ + "code is suitable for merge." user_options = [] - + def run(self): sys.exit(mergeTests()) - + def initialize_options(self): pass - + def finalize_options(self): pass - diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000000..130085ba42 --- /dev/null +++ b/tox.ini @@ -0,0 +1,49 @@ +[tox] +envlist = + ; qt latest + py{37,38}-{pyqt5,pyside2}_latest + + ; qt 5.12.x (LTS) + py{36,37}-{pyqt5,pyside2}_512 + + ; qt 5.9.7 (LTS) + py36-{pyqt5,pyside2}_59_conda + + ; qt 4.8.7 + py27-{pyqt4,pyside}_conda + +[base] +deps = + pytest + numpy + scipy + pyopengl + flake8 + six + coverage + +[testenv] +passenv = DISPLAY XAUTHORITY +setenv = PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command +deps= + {[base]deps} + pytest-cov + h5py + pyside2_512: pyside2>=5.12,<5.13 + pyqt5_512: pyqt5>=5.12,<5.13 + pyside2_latest: pyside2 + pyqt5_latest: pyqt5 + +conda_deps= + pyside2_59_conda: pyside2=5.9 + pyqt5_59_conda: pyqt=5.9 + pyqt4_conda: pyqt=4 + pyside_conda: pyside + +conda_channels= + conda-forge + free + +commands= + python -c "import pyqtgraph as pg; pg.systemInfo()" + pytest {posargs:}