diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..758a37e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,9 @@ +[run] +parallel = True +concurrency = multiprocessing,thread +omit = + */qwt/tests/* + +[report] +exclude_lines = + if __name__ == .__main__.: \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..60f01f7 --- /dev/null +++ b/.env.template @@ -0,0 +1 @@ +PYTHONPATH=. \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..96f1e35 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,209 @@ +# PythonQwt AI Coding Agent Instructions + +## Project Overview + +**PythonQwt** is a pure Python implementation of the Qwt C++ plotting library. It provides low-level Qt plotting widgets that form the foundation for higher-level libraries like PlotPy. + +### Technology Stack + +- **Python**: 3.9+ +- **Core**: NumPy (≥1.21), QtPy (≥1.9) +- **GUI**: Qt via QtPy (PyQt5/PyQt6/PySide6) +- **Testing**: pytest +- **Linting**: Ruff, Pylint + +### Architecture + +``` +qwt/ +├── plot.py # QwtPlot main widget +├── plot_canvas.py # Plot canvas +├── plot_curve.py # QwtPlotCurve +├── plot_marker.py # QwtPlotMarker +├── plot_grid.py # QwtPlotGrid +├── scale_*.py # Scale engine, map, division, drawing +├── symbol.py # QwtSymbol for markers +├── legend.py # QwtLegend +├── text.py # QwtText, QwtTextLabel +├── graphic.py # QwtGraphic +└── tests/ # pytest suite +``` + +## Development Workflows + +### Running Commands + +**Always use `scripts/run_with_env.py`** to load `.env` before running Python commands: + +```powershell +python scripts/run_with_env.py python -m pytest --ff +python scripts/run_with_env.py python -m ruff format +python scripts/run_with_env.py python -m ruff check --fix +``` + +### Running Test Launcher + +```powershell +PythonQwt-tests # GUI test launcher +PythonQwt-tests --mode unattended # Headless tests +``` + +Or from Python: + +```python +from qwt import tests +tests.run() +``` + +## Core Patterns + +### Basic Plot Creation + +```python +import numpy as np +from qtpy import QtWidgets as QW +import qwt + +app = QW.QApplication([]) + +# Create plot widget +plot = qwt.QwtPlot("My Plot Title") +plot.insertLegend(qwt.QwtLegend(), qwt.QwtPlot.BottomLegend) + +# Add curves +x = np.linspace(0, 10, 100) +qwt.QwtPlotCurve.make(x, np.sin(x), "Sine", plot, + linecolor="blue", antialiased=True) + +# Add grid +grid = qwt.QwtPlotGrid() +grid.attach(plot) + +plot.resize(600, 400) +plot.show() +app.exec_() +``` + +### QwtPlotCurve Factory Method + +The `make` class method simplifies curve creation: + +```python +curve = qwt.QwtPlotCurve.make( + x, y, # Data arrays + title="My Curve", # Legend title + plot=plot, # Parent plot (auto-attaches) + linecolor="red", # Line color + linewidth=2, # Line width + linestyle=Qt.DashLine, # Qt line style + antialiased=True, # Anti-aliasing + symbol=qwt.QwtSymbol( # Marker symbol (QwtSymbol instance) + qwt.QwtSymbol.Ellipse, + QBrush(Qt.yellow), + QPen(Qt.red, 2), + QSize(8, 8), + ), +) +``` + +### Key Classes + +| Class | Purpose | +|-------|---------| +| `QwtPlot` | Main plot widget | +| `QwtPlotCurve` | 2D curve item | +| `QwtPlotMarker` | Point/line markers | +| `QwtPlotGrid` | Grid lines | +| `QwtLegend` | Legend widget | +| `QwtSymbol` | Marker symbols | +| `QwtLinearScaleEngine` | Linear scale calculations | +| `QwtLogScaleEngine` | Logarithmic scale calculations | +| `QwtScaleMap` | Scale transformations | +| `QwtText` | Rich text labels | +| `QwtDateTimeScaleDraw` | Datetime axis tick labels | +| `QwtDateTimeScaleEngine` | Datetime scale divisions | + +### Scale Configuration + +```python +# Set axis titles +plot.setAxisTitle(qwt.QwtPlot.xBottom, "Time (s)") +plot.setAxisTitle(qwt.QwtPlot.yLeft, "Amplitude") + +# Set axis scale +plot.setAxisScale(qwt.QwtPlot.xBottom, 0, 100) + +# Logarithmic scale +plot.setAxisScaleEngine(qwt.QwtPlot.yLeft, qwt.QwtLogScaleEngine()) +``` + +### Symbols and Markers + +```python +# Create marker +marker = qwt.QwtPlotMarker() +marker.setSymbol(qwt.QwtSymbol( + qwt.QwtSymbol.Diamond, + QBrush(Qt.yellow), + QPen(Qt.red, 2), + QSize(10, 10) +)) +marker.setValue(x_pos, y_pos) +marker.attach(plot) +``` + +## Coding Conventions + +### Qt Imports + +Use QtPy for Qt binding abstraction: + +```python +from qtpy.QtCore import Qt, QSize +from qtpy.QtGui import QPen, QBrush, QColor +from qtpy.QtWidgets import QWidget +``` + +### Docstrings + +Standard Python docstrings: + +```python +def setData(self, x, y): + """Set curve data. + + :param x: X coordinates (array-like) + :param y: Y coordinates (array-like) + """ +``` + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `qwt/plot.py` | QwtPlot implementation | +| `qwt/plot_curve.py` | QwtPlotCurve with `make()` factory | +| `qwt/scale_engine.py` | Linear/log/datetime scale engines | +| `qwt/scale_map.py` | Scale transformations | +| `qwt/symbol.py` | QwtSymbol definitions | +| `qwt/tests/__init__.py` | Test launcher | + +## Limitations vs C++ Qwt + +The following are **not implemented** (PlotPy provides these): + +- `QwtPlotZoomer` - Use PlotPy's zoom tools +- `QwtPicker` - Use PlotPy's interactive tools +- `QwtPlotPicker` - Use PlotPy's selection tools + +Only essential plot items are implemented: +- `QwtPlotItem` (base) +- `QwtPlotCurve` +- `QwtPlotMarker` +- `QwtPlotGrid` +- `QwtPlotSeriesItem` + +## Related Projects + +- **guidata**: Dataset/parameter framework (sibling) +- **PlotPy**: High-level plotting using PythonQwt (downstream) diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml new file mode 100644 index 0000000..34d255a --- /dev/null +++ b/.github/workflows/build_deploy.yml @@ -0,0 +1,41 @@ +name: Build and upload to PyPI + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + test-pyqt5: + uses: ./.github/workflows/test-PyQt5.yml + + test-pyqt6: + uses: ./.github/workflows/test-PyQt6.yml + + test-pyside6: + uses: ./.github/workflows/test-PySide6.yml + + deploy: + needs: [test-pyqt5, test-pyqt6, test-pyside6] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test-PyQt5.yml b/.github/workflows/test-PyQt5.yml new file mode 100644 index 0000000..18b4cb6 --- /dev/null +++ b/.github/workflows/test-PyQt5.yml @@ -0,0 +1,45 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +# Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_call: + +jobs: + build: + + env: + DISPLAY: ':99.0' + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + python -m pip install --upgrade pip + python -m pip install ruff pytest + pip install PyQt5 qtpy numpy + pip install . + - name: Lint with Ruff + run: ruff check --output-format=github qwt + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/test-PyQt6.yml b/.github/workflows/test-PyQt6.yml new file mode 100644 index 0000000..4bd9efa --- /dev/null +++ b/.github/workflows/test-PyQt6.yml @@ -0,0 +1,46 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +# Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_call: + +jobs: + build: + + env: + DISPLAY: ':99.0' + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils + sudo apt install libegl1 libxcb-cursor0 + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + python -m pip install --upgrade pip + python -m pip install ruff pytest + pip install PyQt6 qtpy numpy + pip install . + - name: Lint with Ruff + run: ruff check --output-format=github qwt + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/test-PySide6.yml b/.github/workflows/test-PySide6.yml new file mode 100644 index 0000000..965509f --- /dev/null +++ b/.github/workflows/test-PySide6.yml @@ -0,0 +1,46 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +# Inspired from https://pytest-qt.readthedocs.io/en/latest/troubleshooting.html#github-actions + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_call: + +jobs: + build: + + env: + DISPLAY: ':99.0' + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 x11-utils + sudo apt install libegl1 libxcb-cursor0 + /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -screen 0 1920x1200x24 -ac +extension GLX + python -m pip install --upgrade pip + python -m pip install ruff pytest + pip install PySide6 qtpy numpy + pip install . + - name: Lint with Ruff + run: ruff check --output-format=github qwt + - name: Test with pytest + run: | + pytest diff --git a/.gitignore b/.gitignore index 9e15c43..c19ea1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ .spyderproject +.spyproject +qwt-6.* +qwt/tests/demo.png +doc.zip +doctmp/ + +# Visual Studio Code +.env # Created by https://www.gitignore.io/api/python @@ -62,3 +70,11 @@ docs/_build/ # PyBuilder target/ +# Local benchmark venvs (issue #93) +.venvs/ + +# Performance investigation artifacts (see scripts/README.md) +shots/ +profile.out +*.prof_stats +*.lprof diff --git a/.hgignore b/.hgignore deleted file mode 100644 index 1060afa..0000000 --- a/.hgignore +++ /dev/null @@ -1,29 +0,0 @@ -\.pyc$ -\.so$ -\.settings$ -\.ropeproject$ -\.spyderproject$ -\.orig$ -\.chm$ -\.pyd$ -\.rar$ -\.gui$ -\.pickle$ -^build/ -^qwt-6.1.0/ -^qwt-6.1.2/ -^dist/ -^tests/build -^tests/dist -Thumbs.db$ -MANIFEST$ -loadsavecanvas.gui$ -loadsavecanvas.h5$ -\.pot$ -mandelbrot.c$ -histogram2d.c$ -~$ -syntax: glob -tests/test*.tif -tests/test*.png -tests/contrast.png diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..f071bdf --- /dev/null +++ b/.pylintrc @@ -0,0 +1,18 @@ +[FORMAT] +# Essential to be able to compare code side-by-side (`black` default setting) +# and best compromise to minimize file size +max-line-length=88 + +[TYPECHECK] +ignored-modules=qtpy.QtWidgets,qtpy.QtCore,qtpy.QtGui + +[MESSAGES CONTROL] +disable=wrong-import-order + +[DESIGN] +max-args=8 # default: 5 +max-attributes=12 # default: 7 +max-branches=17 # default: 12 +max-locals=20 # default: 15 +min-public-methods=0 # default: 2 +max-public-methods=25 # default: 20 \ No newline at end of file diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..1c001a5 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.11" + jobs: + post_create_environment: + - pip install QtPy PyQt5 numpy +sphinx: + configuration: doc/conf.py +formats: + - pdf +python: + install: + - method: pip + path: . + extra_requirements: + - doc diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..77a04e7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python : Test launcher", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/qwt/tests/__init__.py", + "console": "integratedTerminal" + }, + { + "name": "Python : Current file", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..16c89ec --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,31 @@ +{ + "[bat]": { + "files.encoding": "cp850", + }, + "editor.rulers": [ + 88 + ], + "files.exclude": { + "**/__pycache__": true, + "**/.pytest_cache": true, + "**/.hypothesis": true, + "**/*.pyc": true, + "**/*.pyo": true, + "**/*.pyd": true, + ".venv": true + }, + "files.trimFinalNewlines": true, + "files.trimTrailingWhitespace": true, + "editor.formatOnSave": true, + "python.analysis.autoFormatStrings": true, + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestPath": "pytest", + "python.testing.pytestArgs": [], + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + }, +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a67d8ef --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,380 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "🧽 Ruff Formatter", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "ruff", + "format", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🔦 Ruff Linter", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "ruff", + "check", + "--fix", + ], + "options": { + "cwd": "${workspaceFolder}", + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🧽🔦 Ruff", + "dependsOrder": "sequence", + "dependsOn": [ + "🧽 Ruff Formatter", + "🔦 Ruff Linter", + ], + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🔦 Pylint", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "pylint", + "qwt", + "--disable=fixme,C,R,W", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + "type": "shell", + }, + { + "label": "🚀 Pytest", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "pytest", + "--ff", + ], + "options": { + "cwd": "${workspaceFolder}", + "env": { + "UNATTENDED": "1", + }, + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "dedicated", + "showReuseMessage": true, + "clear": true, + }, + "type": "shell", + }, + { + "label": "🧪 Coverage tests", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "coverage", + "run", + "-m", + "pytest", + "qwt", + ], + "options": { + "cwd": "${workspaceFolder}", + "env": { + "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc", + }, + "statusbar": { + "hide": true, + }, + }, + "group": { + "kind": "test", + "isDefault": true, + }, + "presentation": { + "panel": "dedicated", + }, + "problemMatcher": [], + }, + { + "label": "📊 Coverage full", + "type": "shell", + "windows": { + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine; if ($?) { ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html; if ($?) { start htmlcov\\index.html } }", + }, + "linux": { + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine && ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html && xdg-open htmlcov/index.html", + }, + "osx": { + "command": "${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage combine && ${command:python.interpreterPath} scripts/run_with_env.py ${command:python.interpreterPath} -m coverage html && open htmlcov/index.html", + }, + "options": { + "cwd": "${workspaceFolder}", + "env": { + "COVERAGE_PROCESS_START": "${workspaceFolder}/.coveragerc", + }, + }, + "presentation": { + "panel": "dedicated", + }, + "problemMatcher": [], + "dependsOrder": "sequence", + "dependsOn": [ + "🧪 Coverage tests", + ], + }, + { + "label": "📷 Take test screenshots", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "qwt/tests/__init__.py", + "--mode", + "screenshots", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "presentation": { + "clear": true, + "panel": "dedicated", + }, + "problemMatcher": [], + }, + { + "label": "📷 Take doc screenshots", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "doc/plot_example.py", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "presentation": { + "panel": "dedicated", + }, + "problemMatcher": [], + }, + { + "label": "📷 Take symbol screenshots", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "doc/symbol_path_example.py", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "presentation": { + "panel": "dedicated", + }, + "problemMatcher": [], + }, + { + "label": "📷 Take screenshots", + "dependsOrder": "sequence", + "dependsOn": [ + "📷 Take test screenshots", + "📷 Take doc screenshots", + "📷 Take symbol screenshots", + ], + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "panel": "dedicated", + }, + }, + { + "label": "🧹 Clean Up", + "type": "shell", + "windows": { + "command": "Get-ChildItem -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force; Remove-Item -Recurse -Force -ErrorAction SilentlyContinue build, dist, PythonQwt.egg-info, MANIFEST, htmlcov, .coverage, coverage.xml, sitecustomize.py; Remove-Item -Force -ErrorAction SilentlyContinue .coverage.*", + }, + "linux": { + "command": "find . -type d -name __pycache__ -exec rm -rf {} + ; rm -rf build dist PythonQwt.egg-info MANIFEST htmlcov .coverage coverage.xml sitecustomize.py .coverage.*", + }, + "osx": { + "command": "find . -type d -name __pycache__ -exec rm -rf {} + ; rm -rf build dist PythonQwt.egg-info MANIFEST htmlcov .coverage coverage.xml sitecustomize.py .coverage.*", + }, + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false, + }, + }, + { + "label": "📚 Build documentation", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "sphinx", + "build", + "doc", + "${workspaceFolder}/build/doc", + "-b", + "html", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": true, + }, + "presentation": { + "clear": true, + "echo": true, + "focus": false, + "panel": "dedicated", + "reveal": "always", + "showReuseMessage": true, + }, + }, + { + "label": "🌐 Open HTML doc", + "type": "shell", + "windows": { + "command": "start build/doc/index.html", + }, + "linux": { + "command": "xdg-open build/doc/index.html", + }, + "osx": { + "command": "open build/doc/index.html", + }, + "options": { + "cwd": "${workspaceFolder}", + }, + "problemMatcher": [], + }, + { + "label": "📦 Build package", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "scripts/run_with_env.py", + "${command:python.interpreterPath}", + "-m", + "build", + ], + "options": { + "cwd": "${workspaceFolder}", + }, + "group": { + "kind": "build", + "isDefault": false, + }, + "presentation": { + "clear": true, + "panel": "dedicated", + }, + "problemMatcher": [], + "dependsOrder": "sequence", + "dependsOn": [ + "🧹 Clean Up", + ], + }, + ], +} \ No newline at end of file diff --git a/CHANGELOG b/CHANGELOG deleted file mode 100644 index 2119003..0000000 --- a/CHANGELOG +++ /dev/null @@ -1,5 +0,0 @@ -= History of changes = - -== Version 6.1.2 == - -First public release. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b9de2d8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,376 @@ +# PythonQwt Releases + +## Version 0.16.0 + +### Performance + +- Major performance optimizations addressing [Issue #93](https://github.com/PlotPyStack/PythonQwt/issues/93) (rendering performance degradation with Qt 6, also benefitting Qt 5): + - `QwtText`: removed unnecessary `QObject` inheritance and added a font key cache to avoid expensive `QFont` hashing on every text rendering operation + - `QwtText`: cached Qt alignment and text format flags as plain integers to bypass `PyQt6` enum overhead in hot paths + - `QwtText`: enabled font key caching fast path for Qt 6, kept disabled for Qt 5 to preserve consistent text rendering + - `QwtGraphic` and `QwtPainterCommand`: cached Qt flags as integers to reduce per-command overhead, especially under PyQt6 + - `QwtScaleDraw`, `QwtScaleEngine`, `QwtScaleMap` and `QwtScaleDiv`: micro-optimizations on the tick computation and coordinate transform code paths +- Added benchmarking and visual regression scripts under `scripts/` (`bench_qt.ps1`, `bench_plotpy_loadtest.py`, `profile_loadtest.py`, `lineprofile_loadtest.py`, `capture_screenshots.py`, `diff_screenshots.py`) to measure and validate rendering performance and correctness + +### Bug fixes + +- Merged [PR #105](https://github.com/PlotPyStack/PythonQwt/pull/105): fixed legend icon being rendered incorrectly - thanks to @Adrian-B-Moreira +- Fixed `QPaintDevice` warnings on `DevicePixelRatio` and `DevicePixelRatioScaled` metrics in `QwtNullPaintDevice` +- Fixed integer division in `QwtScaleEngine` so that medium ticks are actually produced (previously, the medium tick step was truncated to zero in some configurations) +- Fixed `QwtScaleMap` rectangle transform and degenerate scale handling (when source or destination interval has zero width) + +### Other changes + +- Internal refactor: removed unnecessary `QObject` inheritance from `QwtText` (`QwtText` instances are no longer `QObject` subclasses; this is an internal change with no impact on the public plotting API, but downstream code relying on Qt signals/slots on `QwtText` instances should be adapted) +- Development workflow: replaced legacy `.bat` scripts with a unified `scripts/run_with_env.py` environment loader, refactored VS Code tasks, added coverage tasks and CI gating of PyPI deployment on the test suite passing +- Documentation: updated README, Sphinx documentation, dependencies and added AI coding agent instructions + +## Version 0.15.0 + +- Added support for `QwtDateTimeScaleDraw` and `QwtDateTimeScaleEngine` for datetime axis support (see `QwtDateTimeScaleDraw` and `QwtDateTimeScaleEngine` classes in the `qwt` module) +- Improved font rendering for rotated text in `QwtPlainTextEngine.draw` method: disabled font hinting to avoid character misalignment in rotated text + +## Version 0.14.6 + +- Fixed [Issue #100](https://github.com/PlotPyStack/PythonQwt/issues/100) - TypeError in `QwtSymbol.drawSymbol` method due to outdated `renderSymbols` call +- Fixed [Issue #101](https://github.com/PlotPyStack/PythonQwt/issues/101) - `RuntimeWarning: overflow encountered in cast` when plotting `numpy.float32` curve data +- Merged [PR #103](https://github.com/PlotPyStack/PythonQwt/pull/103): [FIX] wrong handling of `border.rectList` with PySide6 backend - thanks to @martinschwinzerl + +## Version 0.14.5 + +- Merged [PR #98](https://github.com/PlotPyStack/PythonQwt/pull/98): Fix legend still being visible after removed +- Merged [PR #99](https://github.com/PlotPyStack/PythonQwt/pull/99): Fix `QRectF` to `QRect` cast in `QwtPainterClass.drawBackground` + +## Version 0.14.4 + +- Fixed canvas rectangle type in `drawItems` method call in `QwtPlot.drawCanvas` (was causing a hard crash when printing to PDF a canvas with upstream `PlotPy` project) + +## Version 0.14.3 + +- Fixed [Issue #94](https://github.com/PlotPyStack/PythonQwt/issues/94) - Different logarithmic scale behavior when compared to Qwt +- Merged [PR #91](https://github.com/PlotPyStack/PythonQwt/pull/91): Fix: legend now showing up when enabled later - thanks to @nicoddemus +- Removed `QwtPlotItem.setIcon` and `QwtPlotItem.icon` methods (introduced in 0.9.0 but not used in PythonQwt) + +## Version 0.14.2 + +- Merged [PR #89](https://github.com/PlotPyStack/PythonQwt/pull/89): fixed call to `ScaleEngine.autoScale` in `QwtPlot.updateAxes` (returned values were not used) - thanks to @nicoddemus +- Merged [PR #90](https://github.com/PlotPyStack/PythonQwt/pull/90): updated `QwtLinearScaleEngine.autoScale` method implementation to the latest Qwt version - thanks to @nicoddemus + +## Version 0.14.1 + +- Handled `RuntimeError` when running `test_eventfilter.py` on Ubuntu 22.04 (Python 3.12, PyQt5) +- Fixed `ResourceWarning: unclosed file` in `test_cpudemo.py` (purely test issue) +- Fixed segmentation fault in `test_multidemo.py` (purely test issue, related to test utility module `qwt.tests.utils`) +- Update GitHub actions to use the latest versions of actions/checkout, actions/setup-python, ... + +## Version 0.14.0 + +- Dropped support for Python 3.8 + +## Version 0.12.7 + +- Fixed random crashes (segfaults) on Linux related to conflicts between Qt and Python reference counting mechanisms: + - This issue was only happening on Linux, and only with Python 3.12, probably due to changes in Python garbage collector behavior introduced in Python 3.12. Moreover, it was only triggered with an extensive test suite, such as the one provided by the `PlotPy` project. + - The solution was to derive all private classes containing Qt objects from `QObject` instead of `object`, in order to let Qt manage the reference counting of its objects. + - This change was applied to the following classes: + - `QwtLinearColorMap_PrivateData` + - `QwtColumnSymbol_PrivateData` + - `QwtDynGridLayout_PrivateData` + - `QwtGraphic_PrivateData` + - `QwtLegendLabel_PrivateData` + - `QwtNullPaintDevice_PrivateData` + - `QwtPlotCanvas_PrivateData` + - `QwtPlotDirectPainter_PrivateData` + - `QwtPlotGrid_PrivateData` + - `QwtPlotLayout_PrivateData` + - `QwtPlotMarker_PrivateData` + - `QwtPlotRenderer_PrivateData` + - `QwtPlot_PrivateData` + - `QwtAbstractScaleDraw_PrivateData` + - `QwtScaleDraw_PrivateData` + - `QwtScaleWidget_PrivateData` + - `QwtSymbol_PrivateData` + - `QwtText_PrivateData` +- Removed deprecated code regarding PyQt4 compatibility + +## Version 0.12.6 + +- Fixed random crashes (segfaults) on Linux related to Qt objects stored in cache data structures (`QwtText` and `QwtSymbol`) + +- Test suite can simply be run with `pytest` and specific configuration (`conftest.py`) will be taken into account (previously, the test suite has to be run with `pytest qwt` in order to be successfully configured) + +## Version 0.12.5 + +- Add support for NumPy 2.0: + - Use `numpy.asarray` instead of `numpy.array(..., copy=False)` + - Update requirements to remove the NumPy version upper bound constraint + +## Version 0.12.4 + +- Fixed segmentation fault issue reported in the `PlotPy` project: + - See [PlotPy's Issue #13](https://github.com/PlotPyStack/PlotPy/issues/13) for the original issue. + - The issue was caused by the `QwtSymbol` class constructor, and more specifically by its private data object, which instanciated an empty `QtPainterPath` object, causing a segmentation fault on Linux, Python 3.12 and PyQt5. + +## Version 0.12.3 + +- Fixed `Fatal Python error` issue reported in the `PlotPy` project: + - See [PlotPy's Issue #11](https://github.com/PlotPyStack/PlotPy/issues/11) for the original issue, even if the problem is not directly pointed out in the issue comments. + - The issue was caused by the `QwtAbstractScaleDraw` cache mechanism, which was keeping references to `QSizeF` objects that were deleted by the garbage collector at some point. This was causing a segmentation fault, but only on Linux, and only when executing the `PlotPy` test suite in a specific order. + - Thanks to @yuzibo for helping to reproduce the issue and providing a test case, that is the `PlotPy` Debian package build process. + +## Version 0.12.2 + +For this release, test coverage is 72%. + +- Preparing for NumPy V2 compatibility: this is a work in progress, as NumPy V2 is not yet released. In the meantime, requirements have been updated to exclude NumPy V2. +- Fix `QwtPlot.axisInterval` (was raising `AttributeError`) +- Removed unnecessary dependencies (pytest-qt, pytest-cov) +- Moved `conftest.py` to project root +- Project code formatting: using `ruff` instead of `black` and `isort` + +## Version 0.12.1 + +- Fixed `ColorStops.stops` method (was returning a copy of the list of stops instead of the list itself) + +## Version 0.12.0 + +- 30% performance improvement (measured by `qwt.tests.test_loadtest`) by optimizing the `QwtAbstractScaleDraw.tickLabel` method: + - Suppressed an unnecessary call to `QFont.textSize` (which can be quite slow) + - Cached the text size with the label `QwtText` object +- Added support for margins in `QwtPlot` (see Issue #82): + - Default margins are set to 0.05 (5% of the plot area) at each side of the plot + - Margins are adjustable for each plot axis using `QwtPlot.setAxisMargin` (and `QwtPlot.axisMargin` to get the current value) +- Added an additional margin to the left of ticks labels: this margin is set to one character width, to avoid the labels to be truncated while keeping a tight layout +- Slighly improved the new flat style (see V0.7.0) by selecting default fonts +- API breaking change: `QwtLinearColorMap.colorStops` now returns a list of `ColorStop` objects instead of the list of stop values + +## Version 0.11.2 + +- Fixed `TypeError` on `QwtPlotLayout.minimumSizeHint` + +## Version 0.11.1 + +- Fixed remaining `QwtPainter.drawPixmap` call + +## Version 0.11.0 + +- Dropped support for Python 3.7 and earlier +- Dropped support for PyQt4 and PySide2 +- Removed unnecessary argument `numPoints` in `QwtSymbol.drawSymbols` and `QwtSymbol.renderSymbols` methods +- `QwtPlotCanvas`: fixed `BackingStore` feature (`paintAttribute`) + +## Version 0.10.6 + +- Qt6 support: + - Handled all occurences of deprecated ``QWidget.getContentsMargins`` method. + - Removed references to NonCosmeticDefaultPen + - Fixed `QApplication.desktop` `AttributeError` + - Fixed `QPrinter.HighResolution` `AttributeError` on Linux + - Fixed `QPrinter.setColorMode` `AttributeError` on PyQt6/Linux + - Fixed `QPrinter.setOrientation` deprecation issue + - Fixed `QPrinter.setPaperSize` deprecation issue +- Improved unit tests: + - Ensure that tests are entirely executed before quitting (in unattended mode) + - Added more tests on `qwt.symbols` + - Added tests on `qwt.plot_renderer` +- `qwt.plot_renderer`: fixed resolution type +- `qwt.symbols`: fixed `QPointF` type mismatch +- Removed CHM help file generation (obsolete) + +## Version 0.10.5 + +- [Issue #81](https://github.com/PlotPyStack/PythonQwt/issues/81) - Signal disconnection issue with PySide 6.5.3 + +## Version 0.10.4 + +- [Issue #80](https://github.com/PlotPyStack/PythonQwt/issues/80) - Print to PDF: AttributeError: 'NoneType' object has no attribute 'getContentsMargins' + +## Version 0.10.3 + +- [Issue #79](https://github.com/PlotPyStack/PythonQwt/issues/79) - TypeError: unexpected type 'QSize' (thanks to @luc-j-bourhis) + +- Moved project to the [PlotPyStack](https://github.com/PlotPyStack) organization. + +- Unit tests: added support for ``pytest`` and ``coverage`` (60% coverage as of today) + +- [Issue #74](https://github.com/PlotPyStack/PythonQwt/issues/74) - TypeError: QwtPlotDict.__init__() [...] with PySide 6.5.0 + +- [Issue #77](https://github.com/PlotPyStack/PythonQwt/issues/77) - AttributeError: 'XXX' object has no attribute '_QwtPlot__data' + +- [Issue #72](https://github.com/PlotPyStack/PythonQwt/issues/72) - AttributeError: 'QwtScaleWidget' object has no attribute 'maxMajor' / 'maxMinor' / 'stepSize' + +- [Issue #76](https://github.com/PlotPyStack/PythonQwt/issues/76) - [PySide] AttributeError: 'QwtPlotCanvas' object has no attribute 'Sunken' + +- [Issue #63](https://github.com/PlotPyStack/PythonQwt/issues/71) - TypeError: 'PySide2.QtCore.QRect' object is not subscriptable + +## Version 0.10.2 + +- Fixed type mismatch issues on Linux + +## Version 0.10.1 + +- Added support for PyQt6. + +## Version 0.10.0 + +- Added support for QtPy 2 and PySide6. +- Dropped support for Python 2. + +## Version 0.9.2 + +- Curve plotting: added support for `numpy.float32` data type. + +## Version 0.9.1 + +- Added load test showing a large number of plots (eventually highlights performance issues). +- Fixed event management in `QwtPlot` and removed unnecessary `QEvent.LayoutRequest` emission in `QwtScaleWidget` (caused high CPU usage with `guiqwt.ImageWidget`). +- `QwtScaleDiv`: fixed ticks initialization when passing all arguments to constructor. +- tests/image.py: fixed overriden `updateLegend` signature. + +## Version 0.9.0 + +- `QwtPlot`: set the `autoReplot` option at False by default, to avoid time consuming implicit plot updates. +- Added `QwtPlotItem.setIcon` and `QwtPlotItem.icon` method for setting and getting the icon associated to the plot item (as of today, this feature is not strictly needed in PythonQwt: this has been implemented for several use cases in higher level libraries (see PR #61). +- Removed unused `QwtPlotItem.defaultIcon` method. +- Added various minor optimizations for axes/ticks drawing features. +- Fixed `QwtPlot.canvasMap` when `axisScaleDiv` returns None. +- Fixed alias `np.float` which is deprecated in NumPy 1.20. + +## Version 0.8.3 + +- Fixed simple plot examples (setup.py & plot.py's doc page) following the introduction of the new QtPy dependency (Qt compatibility layer) since V0.8.0. + +## Version 0.8.2 + +- Added new GUI-based test script `PythonQwt-py3` to run the test launcher. +- Added command-line options to the `PythonQwt-tests-py3` script to run all the tests simultenously in unattended mode (`--mode unattended`) or to update all the screenshots (`--mode screenshots`). +- Added internal scripts for automated test in virtual environments with both PyQt5 and PySide2. + +## Version 0.8.1 + +- PySide2 support was significatively improved betwen PythonQwt V0.8.0 and V0.8.1 thanks to the new `qwt.qwt_curve.array2d_to_qpolygonf` function. + +## Version 0.8.0 + +- Added PySide2 support: PythonQwt is now compatible with Python 2.7, Python 3.4+, PyQt4, PyQt5 and PySide2! + +## Version 0.7.1 + +- Changed QwtPlotItem.detachItems signature: removed unnecessary "autoDelete" argument, initialiazing "rtti" argument to None (remove all items) +- Improved Qt universal support (PyQt5, ...) + +## Version 0.7.0 + +- Added convenience functions for creating usual objects (curve, grid, marker, ...): + + - `QwtPlotCurve.make` + - `QwtPlotMarker.make` + - `QwtPlotGrid.make` + - `QwtSymbol.make` + - `QwtText.make` + +- Added new test launcher with screenshots (automatically generated) +- Removed `guidata` dependency thanks to the new specific GUI-based test launcher +- Updated documentation (added more examples, using automatically generated screenshots) +- QwtPlot: added "flatStyle" option, a PythonQwt-exclusive feature improving default plot style (without margin, more compact and flat look) -- option is enabled by default +- QwtAbstractScaleDraw: added option to set the tick color lighter factor for each tick type (minor, medium, major) -- this feature is used with the new flatStyle option +- Fixed obvious errors (+ poor implementations) in untested code parts +- Major code cleaning and formatting + +## Version 0.6.2 + +- Fixed Python crash occuring at exit when deleting objects (Python 3 only) +- Moved documentation to +- Added unattended tests with multiple versions of WinPython: + + - WinPython-32bit-2.7.6.4 + - WinPython-64bit-2.7.6.4 + - WinPython-64bit-3.4.4.3 + - WinPython-64bit-3.4.4.3Qt5 + - WPy64-3680 + - WPy64-3771 + - WPy64-3830 + +- Added PyQt4/PyQt5/PySide automatic switch depending on installed libraries + +## Version 0.6.1 + +- Fixed rounding issue with PythonQwt scale engine (0...1000 is now divided in 200-size steps, as in both Qwt and PyQwt) +- Removed unnecessary mask on scaleWidget (this closes #35) +- CurveBenchmark.py: fixed TypeError with numpy.linspace (NumPy=1.18) + +## Version 0.6.0 + +- Ported changes from Qwt 6.1.2 to Qwt 6.1.5 +- `QwtPlotCanvas.setPaintAttribute`: fixed PyQt4 compatibility issue for BackingStore paint attribute +- Fixed DataDemo.py test script (was crashing ; this closes #41) +- `QwtPainterClass.drawBackground`: fixed obvious bug in untested code (this closes #51) +- `qwtFillBackground`: fixed obvious bug in untested code (this closes #50) +- `QwtPainterClass.fillPixmap`: fixed obvious bug in untested code (this closes #49) +- `QwtStyleSheetRecorder`: fixed obvious bug in untested code (this closes #47, closes #48 and closes #52) +- Added "plot without margins" test for Issue #35 + +## Version 0.5.5 + +- `QwtScaleMap.invTransform_scalar`: avoid divide by 0 +- Avoid error when computing ticks: when the axis was so small that no tick could be drawn, an exception used to be raised + +## Version 0.5.4 + +Fixed an annoying bug which caused scale widget (axis ticks in particular) to be misaligned with canvas grid: the user was forced to resize the plot widget as a workaround + +## Version 0.5.3 + +- Better handling of infinity and `NaN` values in scales (removed `NumPy` warnings) +- Now handling infinity and `NaN` values in series data: removing points that can't be drawn +- Fixed logarithmic scale engine: presence of values <= 0 was slowing down series data plotting + +## Version 0.5.2 + +- Added CHM documentation to wheel package +- Fixed `QwtPlotRenderer.setDiscardFlag`/`setLayoutFlag` args +- Fixed `QwtPlotItem.setItemInterest` args +- Fixed `QwtPlot.setAxisAutoScale`/`setAutoReplot` args + +## Version 0.5.1 + +- Fixed Issue #22: fixed scale issues in [CurveDemo2.py](qwt/tests/CurveDemo2.py) and [ImagePlotDemo.py](qwt/tests/ImagePlotDemo.py) +- `QwtPlotCurve`: sticks were not drawn correctly depending on orientation +- `QwtInterval`: avoid overflows with `NumPy` scalars +- Fixed Issue #28: curve shading was broken since v0.5.0 +- setup.py: using setuptools "entry_points" instead of distutils "scripts" +- Showing curves/plots number in benchmarks to avoid any misinterpretation (see Issue #26) +- Added Python2/Python3 scripts for running tests + +## Version 0.5.0 + +- Various optimizations +- Major API simplification, taking into account the feature that won't be implemented (fitting, rounding, weeding out points, clipping, etc.) +- Added `QwtScaleDraw.setLabelAutoSize`/`labelAutoSize` methods to set the new auto size option (see [documentation](http://pythonhosted.org/PythonQwt/)) +- `QwtPainter`: removed unused methods `drawRoundFrame`, `drawImage` and `drawPixmap` + +## Version 0.4.0 + +- Color bar: fixed axis ticks shaking when color bar is enabled +- Fixed `QwtPainter.drawColorBar` for horizontal color bars (typo) +- Restored compatibility with original Qwt signals (`QwtPlot`, ...) + +## Version 0.3.0 + +Renamed the project (python-qwt --> PythonQwt), for various reasons. + +## Version 0.2.1 + +Fixed Issue #23: "argument numPoints is not implemented" error was showing up when calling `QwtSymbol.drawSymbol(symbol, QPoint(x, y))`. + +## Version 0.2.0 + +Added docstrings in all Python modules and a complete documentation based on Sphinx. See the Overview section for API limitations when comparing to Qwt. + +## Version 0.1.1 + +Fixed Issue #21 (blocking issue *only* on non-Windows platforms when building the package): typo in "PythonQwt-tests" script name (in [setup script](setup.py)) + +## Version 0.1.0 + +First alpha public release. diff --git a/LICENSE b/LICENSE index 8d9ff9e..370a71c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ -python-qwt License Agreement ----------------------------- +PythonQwt License Agreement +--------------------------- [1] Software licensed under the terms of Qwt License @@ -15,13 +15,13 @@ of the MIT License (see [*] and [**]). [3] Software licensed under the terms of PyQwt License -Some files under the "examples" folder at the root directory of the source -package were derived from PyQwt PyQt4 examples and are thus distributed under -the terms of the GPL License from which the PyQwt License 1.0 is derived from +Some files under the "tests" subfolder of the main Python package directory +were derived from PyQwt PyQt4 examples and are thus distributed under the +terms of the GPL License from which the PyQwt License 1.0 is derived from (see [****] for more details). -[*] python-qwt License Agreement for new and exclusive Python material (MIT) +[*] PythonQwt License Agreement for new and exclusive Python material (MIT) Copyright (c) 2015 Pierre Raybaut @@ -77,7 +77,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -[***] python-qwt License Agreement for code translated from C++ (Qwt License) +[***] PythonQwt License Agreement for code translated from C++ (Qwt License) Copyright (c) 2002 Uwe Rathmann, for the original C++ code Copyright (c) 2015 Pierre Raybaut, for the Python translation and optimization @@ -630,7 +630,7 @@ That's all there is to it! Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt code Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -developments (e.g. ported to python-qwt API) +developments (e.g. ported to PythonQwt API) PyQwt LICENSE Version 3, March 2006 diff --git a/MANIFEST.in b/MANIFEST.in index 1def0a7..f814f5f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,2 @@ -recursive-include qwt *.png *.svg *.pot *.po *.mo *.dcm *.ui -recursive-include examples *.py *.png *.svg *.pot *.po *.mo *.dcm *.ui -recursive-include src *.hpp *.cpp *.pyx -recursive-include doc *.py *.rst *.png *.ico -include MANIFEST.in -include LICENSE -include README -include CHANGELOG \ No newline at end of file +graft doc +include *.desktop \ No newline at end of file diff --git a/PythonQwt-tests.desktop b/PythonQwt-tests.desktop new file mode 100644 index 0000000..e4e67b3 --- /dev/null +++ b/PythonQwt-tests.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=PythonQwt-tests +GenericName=PythonQwt Test launcher +Comment=The PythonQwt library provides Qt plotting widgets for Python +TryExec=PythonQwt-tests +Exec=PythonQwt-tests +Icon=PythonQwt.svg +Categories=Education;Science;Physics; diff --git a/README.md b/README.md index c7a96ec..0c3db65 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,147 @@ -# python-qwt +# PythonQwt: Qt plotting widgets for Python -## Purpose and Motivation +[![license](https://img.shields.io/pypi/l/PythonQwt.svg)](./LICENSE) +[![pypi version](https://img.shields.io/pypi/v/PythonQwt.svg)](https://pypi.org/project/PythonQwt/) +[![PyPI status](https://img.shields.io/pypi/status/PythonQwt.svg)](https://github.com/PlotPyStack/PythonQwt) +[![PyPI pyversions](https://img.shields.io/pypi/pyversions/PythonQwt.svg)](https://pypi.python.org/pypi/PythonQwt/) +[![download count](https://img.shields.io/conda/dn/conda-forge/PythonQwt.svg)](https://www.anaconda.com/download/) +[![Documentation Status](https://readthedocs.org/projects/pythonqwt/badge/?version=latest)](https://pythonqwt.readthedocs.io/en/latest/?badge=latest) -The ``python-qwt`` project was initiated to solve -at least temporarily- -the obsolescence issue of `PyQwt` (the Python-Qwt C++ bindings library) -which is no longer maintained. The idea was to translate the original -Qwt C++ code to Python and then to optimize some parts of the code by -writing new modules based on NumPy and other libraries. +ℹ️ Created in 2014 by Pierre Raybaut and maintained by the [PlotPyStack](https://github.com/PlotPyStack) organization. -The ``python-qwt`` package consists of a single Python package named -`qwt` and of a few other files (examples, doc, ...). +![PythonQwt Test Launcher](https://raw.githubusercontent.com/PlotPyStack/PythonQwt/master/qwt/tests/data/testlauncher.png) -## Copyrights +The `PythonQwt` project was initiated to solve -at least temporarily- the obsolescence issue of `PyQwt` (the Python-Qwt C++ bindings library) which is no longer maintained. The idea was to translate the original Qwt C++ code to Python and then to optimize some parts of the code by writing new modules based on NumPy and other libraries. -#### Main code base - - Copyright © 2002 Uwe Rathmann, for the original Qwt C++ code - - Copyright © 2015 Pierre Raybaut, for the Qwt C++ to Python -translation and optimization - - Copyright © 2015 Pierre Raybaut, for the python-qwt specific and -exclusive Python material +The `PythonQwt` package consists of a single Python package named `qwt` and of a few other files (examples, doc, ...). -#### PyQt, PySide and Python2/Python3 compatibility modules - - Copyright © 2009-2013 Pierre Raybaut - - Copyright © 2013-2015 The Spyder Development Team +See documentation [online](https://pythonqwt.readthedocs.io/en/latest/) or [PDF](https://pythonqwt.readthedocs.io/_/downloads/en/latest/pdf/) for more details on the library and [changelog](CHANGELOG.md) for recent history of changes. -#### Some examples - - Copyright © 2003-2009 Gerard Vermeulen, for the original PyQwt code - - Copyright © 2015 Pierre Raybaut, for the PyQt5/PySide port and -further developments (e.g. ported to python-qwt API) +## Sample -## License +```python +import numpy as np +from qtpy import QtWidgets as QW + +import qwt + +app = QW.QApplication([]) + +# Create plot widget +plot = qwt.QwtPlot("Trigonometric functions") +plot.insertLegend(qwt.QwtLegend(), qwt.QwtPlot.BottomLegend) + +# Create two curves and attach them to plot +x = np.linspace(-10, 10, 500) +qwt.QwtPlotCurve.make(x, np.cos(x), "Cosine", plot, linecolor="red", antialiased=True) +qwt.QwtPlotCurve.make(x, np.sin(x), "Sine", plot, linecolor="blue", antialiased=True) + +# Resize and show plot +plot.resize(600, 300) +plot.show() + +app.exec_() +``` + +![Simple plot example](doc/_static/QwtPlot_example.png) + +## Examples (tests) -The `qwt` Python package was partly (>95%) translated from Qwt C++ -library: the associated code is distributed under the terms of the LGPL -license. The rest of the code was either wrote from scratch or strongly -inspired from MIT licensed third-party software. -See included LICENSE file for more details about licensing terms. +The GUI-based test launcher may be executed from Python: + +```python +from qwt import tests +tests.run() +``` + +or from the command line: + +```bash +PythonQwt-tests +``` + +Tests may also be executed in unattended mode: + +```bash +PythonQwt-tests --mode unattended +``` ## Overview -The `qwt` package is a pure Python implementation of Qwt C++ library with -the following limitations. +The `qwt` package is a pure Python implementation of `Qwt` C++ library with the following limitations. + +The following `Qwt` classes won't be reimplemented in `qwt` because more powerful features already exist in `PlotPy`: `QwtPlotZoomer`, `QwtCounter`, `QwtEventPattern`, `QwtPicker`, `QwtPlotPicker`. + +Only the following plot items are currently implemented in `qwt` (the only plot items needed by `PlotPy`): `QwtPlotItem` (base class), `QwtPlotGrid`, `QwtPlotMarker`, `QwtPlotSeriesItem` and `QwtPlotCurve`. + +See "Overview" section in [documentation](https://pythonqwt.readthedocs.io/en/latest/) for more details on API limitations when comparing to Qwt. + +## Roadmap + +The `qwt` package short-term roadmap is the following: -The following `Qwt` classes won't be reimplemented in `qwt` because more -powerful features already exist in `guiqwt`: `QwtPlotZoomer`, -`QwtCounter`, `QwtEventPattern`, `QwtPicker`, `QwtPlotPicker`. +- [X] Drop support for PyQt4 and PySide2 +- [X] Drop support for Python <= 3.8 +- [X] Replace `setup.py` by `pyproject.toml`, using `setuptools` (e.g. see `guidata`) +- [ ] Add more unit tests: the ultimate goal is to reach 90% code coverage -Only the following plot items are currently implemented in `qwt` (the -only plot items needed by `guiqwt`): `QwtPlotItem` (base class), -`QwtPlotItem`, `QwtPlotMarker`, `QwtPlotSeriesItem`, `QwtPlotHistogram`, -`QwtPlotCurve` +## Dependencies and installation -The `QwtClipper` class is not implemented yet (and it will probably be -very difficult or even impossible to implement it in pure Python without -performance issues). As a consequence, when zooming in a plot curve, the -entire curve is still painted (in other words, when working with large -amount of data, there is no performance gain when zooming in). +### Supported Qt versions and bindings -## Dependencies +The whole PlotPyStack set of libraries relies on the [Qt](https://doc.qt.io/) GUI toolkit, thanks to [QtPy](https://pypi.org/project/QtPy/), an abstraction layer which allows to use the same API to interact with different Python-to-Qt bindings (PyQt5, PyQt6, PySide2, PySide6). -### Requirements ### -- Python >=2.6 or Python >=3.0 -- PyQt4 >=4.4 or PyQt5 >= 5.5 -- NumPy >= 1.5 +Compatibility table: -## Installation +| PythonQwt version | PyQt5 | PyQt6 | PySide2 | PySide6 | +|-------------------|-------|-------|---------|---------| +| 0.15 and earlier | ✅ | ⚠️ | ❌ | ⚠️ | +| Latest | ✅ | ✅ | ❌ | ✅ | + +### Requirements + +- Python >=3.9 +- QtPy >= 1.9 (and a Python-to-Qt binding library, see above) +- NumPy >= 1.21 + +### Optional dependencies + +- coverage, pytest (for unit tests) +- sphinx (for documentation generation) + +### Installation + +From PyPI: + +```bash +pip install PythonQwt +``` From the source package: - python setup.py install +```bash +python -m build +``` + +## Performance investigation + +Tooling for performance benchmarks, profiling and visual-regression checks across PyQt5/PyQt6/PySide6 lives in [`scripts/`](scripts/README.md). See [`doc/issue93_optimization_summary.md`](doc/issue93_optimization_summary.md) for a worked example. + +## Copyrights + +### Main code base + +- Copyright © 2002 Uwe Rathmann, for the original Qwt C++ code +- Copyright © 2015 Pierre Raybaut, for the Qwt C++ to Python translation and optimization +- Copyright © 2015 Pierre Raybaut, for the PythonQwt specific and exclusive Python material + +### Some examples + +- Copyright © 2003-2009 Gerard Vermeulen, for the original PyQwt code +- Copyright © 2015 Pierre Raybaut, for the PyQt5/PySide port and further developments (e.g. ported to PythonQwt API) + +## License + +The `qwt` Python package was partly (>95%) translated from Qwt C++ library: the associated code is distributed under the terms of the LGPL license. The rest of the code was either wrote from scratch or strongly inspired from MIT licensed third-party software. + +See included [LICENSE](LICENSE) file for more details about licensing terms. diff --git a/build.bat b/build.bat deleted file mode 100644 index 2b439cb..0000000 --- a/build.bat +++ /dev/null @@ -1,5 +0,0 @@ -rmdir /S /Q build -rmdir /S /Q dist -python setup.py build sdist -python setup.py sdist bdist_wheel --universal -pause \ No newline at end of file diff --git a/doc/_static/PythonQwt_logo.png b/doc/_static/PythonQwt_logo.png new file mode 100644 index 0000000..93e4143 Binary files /dev/null and b/doc/_static/PythonQwt_logo.png differ diff --git a/doc/_static/QwtPlot_example.png b/doc/_static/QwtPlot_example.png new file mode 100644 index 0000000..1110496 Binary files /dev/null and b/doc/_static/QwtPlot_example.png differ diff --git a/doc/_static/panorama.png b/doc/_static/panorama.png new file mode 100644 index 0000000..d30b502 Binary files /dev/null and b/doc/_static/panorama.png differ diff --git a/doc/_static/symbol_path_example.png b/doc/_static/symbol_path_example.png new file mode 100644 index 0000000..d050a80 Binary files /dev/null and b/doc/_static/symbol_path_example.png differ diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..5969162 --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.append(os.path.abspath('.')) + +# -- General configuration ----------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ["sphinx.ext.autodoc"] +try: + import sphinx.ext.viewcode # noqa: F401 + + extensions.append("sphinx.ext.viewcode") +except ImportError: + print("WARNING: the Sphinx viewcode extension was not found", file=sys.stderr) + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "PythonQwt" +import time + +this_year = time.strftime("%Y", time.localtime()) +copyright = "2002 Uwe Rathmann (for the original C++ code/doc), 2015 Pierre Raybaut (for the Python translation/optimization/doc adaptation)" + +# 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. +import qwt + +version = ".".join(qwt.__version__.split(".")[:2]) +# The full version, including alpha/beta/rc tags. +release = qwt.__version__ + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of documents that shouldn't be included in the build. +# unused_docs = [] + +# List of directories, relative to source directory, that shouldn't be searched +# for source files. +exclude_trees = [] + +# The reST default role (used for this markup: `text`) to use for all documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +modindex_common_prefix = ["qwt."] + +autodoc_member_order = "bysource" + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. Major themes that come with +# Sphinx are currently 'default' and 'sphinxdoc'. +try: + import python_docs_theme # noqa: F401 + + html_theme = "python_docs_theme" +except ImportError: + html_theme = "default" + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +## html_theme_options = {'sidebarbgcolor': '#227A2B', +## 'sidebarlinkcolor': '#98ff99'} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +html_title = "%s %s Manual" % (project, version) + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = '%s Manual' % project + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +html_logo = "_static/PythonQwt_logo.png" + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = 'favicon.ico' + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["_static"] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +html_use_modindex = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = '' + +# Output file base name for HTML help builder. +htmlhelp_basename = "PythonQwt" + + +# -- Options for LaTeX output -------------------------------------------------- + +# The paper size ('letter' or 'a4'). +# latex_paper_size = 'letter' + +# The font size ('10pt', '11pt' or '12pt'). +# latex_font_size = '10pt' + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ("index", "qwt.tex", "PythonQwt Manual", "Pierre Raybaut", "manual"), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# Additional stuff for the LaTeX preamble. +# latex_preamble = '' + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_use_modindex = True diff --git a/doc/examples/bodedemo.rst b/doc/examples/bodedemo.rst new file mode 100644 index 0000000..6226f5e --- /dev/null +++ b/doc/examples/bodedemo.rst @@ -0,0 +1,7 @@ +Bode demo +~~~~~~~~~ + +.. image:: /../qwt/tests/data/bodedemo.png + +.. literalinclude:: /../qwt/tests/test_bodedemo.py + :start-after: SHOW diff --git a/doc/examples/cartesian.rst b/doc/examples/cartesian.rst new file mode 100644 index 0000000..bc0a844 --- /dev/null +++ b/doc/examples/cartesian.rst @@ -0,0 +1,7 @@ +Cartesian demo +~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/cartesian.png + +.. literalinclude:: /../qwt/tests/test_cartesian.py + :start-after: SHOW diff --git a/doc/examples/cpudemo.rst b/doc/examples/cpudemo.rst new file mode 100644 index 0000000..58f471f --- /dev/null +++ b/doc/examples/cpudemo.rst @@ -0,0 +1,7 @@ +CPU plot demo +~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/cpudemo.png + +.. literalinclude:: /../qwt/tests/test_cpudemo.py + :start-after: SHOW diff --git a/doc/examples/curvebenchmark1.rst b/doc/examples/curvebenchmark1.rst new file mode 100644 index 0000000..a372c02 --- /dev/null +++ b/doc/examples/curvebenchmark1.rst @@ -0,0 +1,7 @@ +Curve benchmark demo 1 +~~~~~~~~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/curvebenchmark1.png + +.. literalinclude:: /../qwt/tests/test_curvebenchmark1.py + :start-after: SHOW diff --git a/doc/examples/curvebenchmark2.rst b/doc/examples/curvebenchmark2.rst new file mode 100644 index 0000000..2c9daf1 --- /dev/null +++ b/doc/examples/curvebenchmark2.rst @@ -0,0 +1,7 @@ +Curve benchmark demo 2 +~~~~~~~~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/curvebenchmark2.png + +.. literalinclude:: /../qwt/tests/test_curvebenchmark2.py + :start-after: SHOW diff --git a/doc/examples/curvedemo1.rst b/doc/examples/curvedemo1.rst new file mode 100644 index 0000000..13f3c89 --- /dev/null +++ b/doc/examples/curvedemo1.rst @@ -0,0 +1,7 @@ +Curve demo 1 +~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/curvedemo1.png + +.. literalinclude:: /../qwt/tests/test_curvedemo1.py + :start-after: SHOW diff --git a/doc/examples/curvedemo2.rst b/doc/examples/curvedemo2.rst new file mode 100644 index 0000000..8e4919f --- /dev/null +++ b/doc/examples/curvedemo2.rst @@ -0,0 +1,7 @@ +Curve demo 2 +~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/curvedemo2.png + +.. literalinclude:: /../qwt/tests/test_curvedemo2.py + :start-after: SHOW diff --git a/doc/examples/data.rst b/doc/examples/data.rst new file mode 100644 index 0000000..fcdda3a --- /dev/null +++ b/doc/examples/data.rst @@ -0,0 +1,7 @@ +Data demo +~~~~~~~~~ + +.. image:: /../qwt/tests/data/data.png + +.. literalinclude:: /../qwt/tests/test_data.py + :start-after: SHOW diff --git a/doc/examples/errorbar.rst b/doc/examples/errorbar.rst new file mode 100644 index 0000000..7981d68 --- /dev/null +++ b/doc/examples/errorbar.rst @@ -0,0 +1,7 @@ +Error bar demo +~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/errorbar.png + +.. literalinclude:: /../qwt/tests/test_errorbar.py + :start-after: SHOW diff --git a/doc/examples/eventfilter.rst b/doc/examples/eventfilter.rst new file mode 100644 index 0000000..53b4033 --- /dev/null +++ b/doc/examples/eventfilter.rst @@ -0,0 +1,7 @@ +Event filter demo +~~~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/eventfilter.png + +.. literalinclude:: /../qwt/tests/test_eventfilter.py + :start-after: SHOW diff --git a/doc/examples/image.rst b/doc/examples/image.rst new file mode 100644 index 0000000..e145e63 --- /dev/null +++ b/doc/examples/image.rst @@ -0,0 +1,7 @@ +Image plot demo +~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/image.png + +.. literalinclude:: /../qwt/tests/test_image.py + :start-after: SHOW diff --git a/doc/examples/index.rst b/doc/examples/index.rst new file mode 100644 index 0000000..c599332 --- /dev/null +++ b/doc/examples/index.rst @@ -0,0 +1,49 @@ +.. _examples: + +Examples +======== + +The test launcher +----------------- + +A lot of examples are available in the ``qwt.tests`` module :: + + from qwt import tests + tests.run() + +The two lines above execute the ``PythonQwt-tests`` test launcher: + +.. image:: /../qwt/tests/data/testlauncher.png + +GUI-based test launcher can be executed from the command line thanks to the +``PythonQwt-tests`` test script. + +Unit tests may be executed from the command line thanks to the console-based script +``PythonQwt-tests``: ``PythonQwt-tests --mode unattended``. + +Tests +----- + + + +Here are some examples from the `qwt.tests` module: + +.. toctree:: + :maxdepth: 2 + + bodedemo + cartesian + cpudemo + curvebenchmark1 + curvebenchmark2 + curvedemo1 + curvedemo2 + data + errorbar + eventfilter + image + logcurve + mapdemo + multidemo + simple + vertical diff --git a/doc/examples/logcurve.rst b/doc/examples/logcurve.rst new file mode 100644 index 0000000..48eb3ec --- /dev/null +++ b/doc/examples/logcurve.rst @@ -0,0 +1,7 @@ +Log curve plot demo +~~~~~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/logcurve.png + +.. literalinclude:: /../qwt/tests/test_logcurve.py + :start-after: SHOW diff --git a/doc/examples/mapdemo.rst b/doc/examples/mapdemo.rst new file mode 100644 index 0000000..9fba166 --- /dev/null +++ b/doc/examples/mapdemo.rst @@ -0,0 +1,7 @@ +Map demo +~~~~~~~~ + +.. image:: /../qwt/tests/data/mapdemo.png + +.. literalinclude:: /../qwt/tests/test_mapdemo.py + :start-after: SHOW diff --git a/doc/examples/multidemo.rst b/doc/examples/multidemo.rst new file mode 100644 index 0000000..84f80d0 --- /dev/null +++ b/doc/examples/multidemo.rst @@ -0,0 +1,7 @@ +Multi demo +~~~~~~~~~~ + +.. image:: /../qwt/tests/data/multidemo.png + +.. literalinclude:: /../qwt/tests/test_multidemo.py + :start-after: SHOW diff --git a/doc/examples/simple.rst b/doc/examples/simple.rst new file mode 100644 index 0000000..956923d --- /dev/null +++ b/doc/examples/simple.rst @@ -0,0 +1,7 @@ +Really simple demo +~~~~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/simple.png + +.. literalinclude:: /../qwt/tests/test_simple.py + :start-after: SHOW diff --git a/doc/examples/vertical.rst b/doc/examples/vertical.rst new file mode 100644 index 0000000..c1cedc9 --- /dev/null +++ b/doc/examples/vertical.rst @@ -0,0 +1,7 @@ +Vertical plot demo +~~~~~~~~~~~~~~~~~~ + +.. image:: /../qwt/tests/data/vertical.png + +.. literalinclude:: /../qwt/tests/test_vertical.py + :start-after: SHOW diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..4690dcd --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,17 @@ +.. automodule:: qwt + +Contents: + +.. toctree:: + :maxdepth: 2 + + overview + installation + examples/index + reference/index + + +Indices and tables: + +* :ref:`genindex` +* :ref:`search` diff --git a/doc/installation.rst b/doc/installation.rst new file mode 100644 index 0000000..7b840f2 --- /dev/null +++ b/doc/installation.rst @@ -0,0 +1,33 @@ +Installation +============ + +Dependencies +------------ + +Requirements: + * Python 3.9 or higher + * PyQt5 5.15, PyQt6 or PySide6 + * QtPy 1.9 or higher + * NumPy 1.21 or higher + * Sphinx for documentation generation + * pytest, coverage for unit testing + +Installation +------------ + +From PyPI: + + `pip install PythonQwt` + +From the source package: + + `python -m build` + +Help and support +---------------- + +External resources: + + * Bug reports and feature requests: `GitHub`_ + +.. _GitHub: https://github.com/PlotPyStack/PythonQwt diff --git a/doc/issue93_optimization_summary.md b/doc/issue93_optimization_summary.md new file mode 100644 index 0000000..91ba980 --- /dev/null +++ b/doc/issue93_optimization_summary.md @@ -0,0 +1,284 @@ +# Issue #93 — Performance degradation with Qt6: optimization summary + +This document summarises the work done on the `fix/93-performance-degradation-with-qt6` branch to investigate and close the Qt5↔Qt6 performance gap reported in [issue #93](https://github.com/PlotPyStack/PythonQwt/issues/93). It walks through each optimization phase, the diagnostic method used, the change applied, and the measured impact. + +All numbers below were collected on the same Windows 11 machine, Python 3.11.9, with three sibling virtual environments (`.venvs/pyqt5`, `.venvs/pyqt6`, `.venvs/pyside6`), each pinning a single Qt binding (PyQt5 5.15.11 / Qt 5.15.2, PyQt6 6.11.0 / Qt 6.11.0, PySide6 6.11.0 / Qt 6.11.0). + +Two benchmarks were used throughout: + +- **`qwt/tests/test_loadtest.py`** — the PythonQwt micro load test (raw QwtPlot widgets, no PlotPy). Driven by `scripts/bench_qt.ps1`. Reports `Average elapsed time: ms` per binding. +- **PlotPy `test_loadtest`** — `plotpy/tests/benchmarks/test_loadtest.py`, the test cited in the original GitHub issue. Driven by `scripts/bench_plotpy_loadtest.py` (60 plot widgets, 3 runs). + +## Baseline (master, commit `1ab70cd`) + +| Benchmark | PyQt5 | PyQt6 | PySide6 | +|---|---:|---:|---:| +| PythonQwt `test_loadtest` (avg of 5) | ~1 900 ms | ~2 300 ms | ~2 900 ms | +| PlotPy `test_loadtest`, 60 plots (avg of 3) | 25 134 ms | 42 202 ms | 53 160 ms | + +Headline gap on PlotPy: **PyQt6 ≈ +68 % slower than PyQt5**, **PySide6 ≈ +111 % slower than PyQt5**. + +The cProfile traces taken on master pointed at four hot families of code paths inside PythonQwt: + +1. `QwtScaleMap.transform()` — called on every coordinate transformed. +2. `QwtScaleDiv.contains()` and `QwtScaleEngine.contains()/strip()` — called on every tick label candidate. +3. `QwtAbstractScaleDraw.labelRect()` and helpers — called on every drawn tick. +4. `QwtText` / `QwtPlainTextEngine` text-size and text-margin computations — called on every tick label and every plot title. + +All four are amortised over thousands of calls per plot, and all four are sensitive to per-call Python overhead (attribute lookups, QObject machinery, redundant Qt round-trips). That is precisely the kind of overhead that the Qt6 bindings (especially PySide6) make more expensive than Qt5, which explains why a regression that is barely visible on Qt5 becomes a 2× slowdown on Qt6. + +## Phase 1 — cProfile-driven optimizations (commit `ef793e1`) + +**Method.** `scripts/profile_loadtest.py` runs the PythonQwt load test under `cProfile` and dumps a sorted-by-cumulative-time stats file. Diff between PyQt5 and PySide6 traces highlighted the four families above. + +**Changes.** + +- **`qwt/scale_map.py`** — inlined the scalar fast path in `QwtScaleMap.transform()` (avoid the array branch and a method dispatch when the input is a plain Python `float`). +- **`qwt/scale_div.py`** — rewrote `QwtScaleDiv.contains()` as a direct comparison against the cached lower/upper bounds, instead of going through `QwtInterval`. +- **`qwt/scale_engine.py`** — `QwtScaleEngine.contains()` and `QwtScaleEngine.strip()` similarly bypass `QwtInterval` round-trips for the common case. +- **`qwt/scale_draw.py`** — replaced the per-call alignment branching in `labelRect()`/`labelPosition()` with module-level constants (`_ALIGN_BOTTOM`, `_ALIGN_TOP`, `_ALIGN_LEFT`, `_ALIGN_RIGHT`); added a rotation==0 fast path in `labelRect()`; cached the axis `orientation` once in `setAlignment()` instead of recomputing it on every call. +- **`qwt/text.py`** — first round of cleanups around `QwtText.textSize()` and `QwtPlainTextEngine.textMargins()`, plus a per-engine "last seen font id" fast path that skips the `QFontMetricsF` rebuild when the same `QFont` instance is reused (which is the dominant case during a single plot repaint). + +**Results after phase 1** (PythonQwt micro `test_loadtest`, 5 runs each): + +| Binding | Before | After phase 1 | Speedup | +|---|---:|---:|---:| +| PyQt5 | ~1 900 ms | ~620 ms | ×3.0 | +| PyQt6 | ~2 300 ms | ~780 ms | ×2.9 | +| PySide6 | ~2 900 ms | ~960 ms | ×3.0 | + +Phase 1 closed most of the absolute slowdown but did not change the *relative* Qt5↔Qt6 gap — all three bindings benefited roughly equally, because the optimizations attacked Python-side overhead that scales with call count regardless of binding. + +## Phase 2 — line-profiler-driven optimizations (commit `27a0e17`) + +**Method.** `scripts/lineprofile_loadtest.py` instruments the surviving hot functions with `line_profiler` (`@profile`) and re-runs the load test. The line-by-line traces revealed two new dominant costs that did not show up clearly in cProfile: + +1. The `QObject` base class on `QwtText_PrivateData` and on the `_PrivateData` classes inside `qwt/scale_draw.py`. Every instantiation went through Qt's meta-object system, which is dramatically more expensive on PyQt6 / PySide6 than on PyQt5. +2. Repeated calls to `QFont.key()` from within `QwtText.textSize()`, `QwtText.effectiveAscent()` and `QwtPlainTextEngine.textMargins()`. Each call serialises the full font descriptor; the same descriptor is hit thousands of times during a single load test because the same default font instance is reused. + +**Changes.** + +- **`qwt/text.py`** — `QwtText_PrivateData` is now a plain `object` subclass with `__slots__`; no QObject. Added a process-wide `_FONT_KEY_CACHE` keyed by `id(font)` that memoizes `font.key()` (with a hard cap of 1024 entries to avoid unbounded growth). Helper `font_key_cached()` is used by `effectiveAscent`, `QwtPlainTextEngine.textMargins`, and `QwtText.textSize`. +- **`qwt/scale_draw.py`** — the various `_PrivateData` containers also drop `QObject` and use `__slots__`. + +**Results after phase 2** (PythonQwt micro `test_loadtest`, 5 runs each): + +| Binding | Before phase 2 | After phase 2 | Speedup vs phase 1 | Speedup vs master | +|---|---:|---:|---:|---:| +| PyQt5 | ~620 ms | ~445 ms | ×1.4 | ×4.3 | +| PyQt6 | ~780 ms | ~480 ms | ×1.6 | ×4.8 | +| PySide6 | ~960 ms | ~600 ms | ×1.6 | ×4.8 | + +Phase 2 finally closed the *relative* gap as well: Qt6 bindings benefit more than Qt5 from removing QObject inheritance and `font.key()` calls, because the per-call overhead they save is binding-cost-dominated. + +## Phase 3 — screenshot regression analysis + +**Method.** Two new helpers were added in `scripts/`: + +- **`capture_screenshots.py`** — runs each of the 22 PythonQwt visual tests in a subprocess with `PYTHONQWT_TAKE_SCREENSHOTS=1` and copies the resulting PNGs into `shots///`. +- **`diff_screenshots.py`** — pixel-compares two screenshot folders (Pillow + NumPy) and emits a markdown table with `IDENTICAL` / `EQUAL_PIXELS` / `DIFFER` status, plus the count and magnitude of differing pixels. + +A full matrix was captured (master × 3 bindings, fix × 3 bindings, plus self-compare baselines master×master and fix×fix to filter out flaky tests that have inherently random or time-stamped output). + +**Findings.** + +- **PyQt6 and PySide6**: zero new deterministic differences vs master. Every diff that appeared was already present in the master self-compare baseline (the 6 tests `test_cpudemo`, `test_curvebenchmark1/2`, `test_data`, `test_loadtest`, `test_mapdemo`, all of which use random data or timestamps). +- **PyQt5**: 6 *new* deterministic, sub-perceptual differences appeared, in `test_backingstore`, `test_bodedemo`, `test_image`, `test_relativemargin`, `test_symbols`, `test_vertical`. All diffs were tiny (a few dozen pixels each, max magnitude ≤ 26/255), scattered around antialiased text and curve edges. + +### Per-test screenshot status (master vs phase-2 fix, all bindings) + +Each cell aggregates two pixel-diffs per test (master vs `master2` self-compare baseline, and master vs phase-2 fix). The classification rule is: + +- ✅ — both diffs report identical or pixel-equal output (test is fully reproducible *and* the optimization branch did not change it). +- ⚠️ — both diffs are non-zero (test is *intrinsically* flaky — random data, timestamps, live system stats — so any difference is noise, not a regression). +- ❌ — baseline is identical but the fix differs (a real visual regression introduced by the optimization branch). + +| Test | PyQt5 | PyQt6 | PySide6 | +|---|:-:|:-:|:-:| +| `test_backingstore` | ❌ 55 px (max=11) | ✅ | ✅ | +| `test_bodedemo` | ❌ 39 px (max=16) | ✅ | ✅ | +| `test_cartesian` | ✅ | ✅ | ✅ | +| `test_cpudemo` | ⚠️ | ⚠️ | ⚠️ | +| `test_curvebenchmark1` | ⚠️ | ⚠️ | ⚠️ | +| `test_curvebenchmark2` | ⚠️ | ⚠️ | ⚠️ | +| `test_curvedemo1` | ✅ | ✅ | ✅ | +| `test_curvedemo2` | ✅ | ✅ | ✅ | +| `test_data` | ⚠️ | ⚠️ | ⚠️ | +| `test_errorbar` | ✅ | ✅ | ✅ | +| `test_eventfilter` | ✅ | ✅ | ✅ | +| `test_highdpi` | ✅ | ✅ | ✅ | +| `test_image` | ❌ 6 px (max=9) | ✅ | ✅ | +| `test_loadtest` | ⚠️ | ⚠️ | ⚠️ | +| `test_logcurve` | ✅ | ✅ | ✅ | +| `test_mapdemo` | ⚠️ | ⚠️ | ⚠️ | +| `test_multidemo` | ✅ | ✅ | ✅ | +| `test_relativemargin` | ❌ 72 px (max=11) | ✅ | ✅ | +| `test_simple` | ✅ | ✅ | ✅ | +| `test_stylesheet` | ✅ | ✅ | ✅ | +| `test_symbols` | ❌ 4 px (max=9) | ✅ | ✅ | +| `test_vertical` | ❌ 88 px (max=26) | ✅ | ✅ | + +**Summary at end of phase 3.** PyQt6 and PySide6: 16 ✅ / 6 ⚠️ / **0 ❌**. PyQt5: 10 ✅ / 6 ⚠️ / **6 ❌**. The 6 ❌ entries on PyQt5 are the regression that phase 4 fixes. + +**Root cause.** The id-keyed `font.key()` cache subtly changes the order in which the Qt5 font engine is asked to materialise specific font descriptors. On Qt5, the font engine hints text glyphs slightly differently depending on first-touch order — invisible to a human, but bit-non-identical to master. Qt6's font engine does not show this sensitivity. + +## Phase 4 — Option A: gate the font-key fast path on Qt5 (current state) + +**Change.** In `qwt/text.py`, the id-keyed cache is now guarded by a Qt-version check: + +```python +from qtpy import QT_VERSION as _QT_VERSION + +_USE_FONT_KEY_FAST_PATH = not str(_QT_VERSION).startswith("5.") + +def font_key_cached(font) -> str: + if not _USE_FONT_KEY_FAST_PATH: + return font.key() + # ... id-keyed cache lookup ... +``` + +On Qt5 this becomes a thin pass-through to `font.key()` — bit-identical output to master is restored. On Qt6 (where it actually matters most for this issue) the optimization stays in place. + +**Verification.** + +1. **Screenshot regression** — re-ran PyQt5 capture and diff. The 6 ❌ entries from the phase 3 table all flip to ✅. Final per-binding tally becomes **16 ✅ / 6 ⚠️ / 0 ❌** on every binding — i.e. byte-identical output to master on every test that is reproducible at all. +2. **Test suite** — `pytest -q` with `PYTHONQWT_UNATTENDED_TESTS=1` on all three bindings: + - PyQt5: 26 passed, 1 skipped + - PyQt6: 26 passed, 1 skipped + - PySide6: 26 passed, 1 skipped, 1 warning +3. **Performance** — PyQt5 micro-bench rose from ~445 ms to ~450–550 ms (≈ +5 ms, well within the run-to-run noise). Qt6 numbers are unchanged. + +## Phase 5 — closing the residual Qt5↔Qt6 gap + +After phases 1–4 the Qt6 path was still measurably slower than Qt5 on the micro load test (~+20 % / +100 ms). The goal of phase 5 was to **understand and remove that residual gap**, not just to keep optimising blindly. + +**Method.** A second cProfile + `line_profiler` pass was run on the post-phase-4 tip, this time focused on the diff between PyQt5 and PyQt6 traces (rather than absolute hotspots). Three concrete root causes were identified, all specific to the Qt6 binding: + +1. **Python `enum.IntFlag` arithmetic.** PyQt6 exposes Qt enums as `enum.Flag` subclasses; every `flags & Qt.SomeFlag` test goes through `enum.__and__ → enum.__call__ → enum.__new__` (~6 µs each). PyQt5 uses plain ints, so the same code costs ~50 ns there. cProfile attributed ≈ 62 ms / run on PyQt6 to `enum.py`, **0 ms on PyQt5**. The single worst caller was `QwtPainterCommand.__init__`, which performs **twelve** successive `flags & QPaintEngine.DirtyXxx` tests per painter command — at ~300 commands per load-test run that is 3 600 enum operations alone. +2. **`QFont.key()` is ~3× slower per call on PyQt6.** Per-call sip dispatch costs were measured at 3.3 µs (PyQt5) vs 9.3 µs (PyQt6) for cheap getters. `font.key()` was the single biggest residual hotspot inside `QwtText.textSize()`. +3. **The `id(font)` fast path misfires on PyQt6.** PyQt6 returns a *fresh* Python wrapper around the same underlying `QFont` on most calls, so `id(font)` changes between calls and the id-keyed cache misses ~92 % of the time (vs ~60 % on PyQt5). The slower `font.key()` path then takes over, compounding cause #2. + +**Changes.** + +- **`qwt/painter_command.py`** — added a `_flag_int(flag)` helper (PyQt5/PyQt6 portable) and module-level `_DIRTY_PEN`, `_DIRTY_BRUSH`, … int constants. The State branch in `__init__` casts `state.state()` to int *once* and bitwise-tests against the cached int constants instead of going through `enum.__and__` 12 times per command. +- **`qwt/graphic.py`** — same pattern in `qwtPaintCommand`'s State-replay branch (12 more flag tests per replayed command). +- **`qwt/text.py`** — same pattern for `Qt.AlignXxx` flags (`_ALIGN_LEFT`, `_ALIGN_RIGHT`, …) in the hot bitwise-test sites in `taggedRichText()`, `QwtTextLabel.sizeHint()/heightForWidth()/textRect()`. The `setRenderFlags()` setter still stores the value as `Qt.AlignmentFlag` so downstream Qt APIs that strictly require an enum on PyQt6 (`QTextOption.setAlignment`, `QPainter.drawText`, `QFontMetrics.boundingRect`) keep working — only the per-test bitwise sites cast back to int locally. +- **`qwt/text.py`** — **replaced the entire `id(font) → font.key()` cache** with a tuple-key cache. The new `font_key_cached(font)` returns an interned `(family, pixelSize-or-pointSizeF, weight, italic, stretch, styleStrategy)` tuple instead of `font.key()`. The two-level design keeps the original id-keyed fast path for repeated calls with the same QFont instance, and falls back to the tuple key (which never calls `QFont.key()`) for the PyQt6 case where wrappers churn. The same key is now also used by `fontmetrics()`/`fontmetrics_f()` — they previously called `font.toString()` per lookup, another ~3× more expensive on PyQt6. +- The Qt-5 fast-path gate (`_USE_FONT_KEY_FAST_PATH`) introduced in phase 4 is no longer needed and was removed: since the new cache never calls `font.key()`, the font-engine first-touch ordering issue that motivated the gate cannot occur. + +**Verification.** + +- **Test suite** — `pytest -q` with `PYTHONQWT_UNATTENDED_TESTS=1` on both bindings: PyQt5 26 passed / 1 skipped, PyQt6 26 passed / 1 skipped. Same as phase 4. +- **Performance** — PythonQwt micro `test_loadtest`, 10 runs each, run back-to-back on the same machine immediately after phase 5: + +| Config | PyQt5 ms (median / mean) | PyQt6 ms (median / mean) | Δ (PyQt6 − PyQt5) | PyQt6/PyQt5 | +|---|--:|--:|--:|--:| +| `master` (no optimisations) | 798 / 805 | 1 000 / 986 | +202 ms | **+25 %** | +| `fix/93` tip (end of phase 4) | 511 / 517 | 611 / 622 | +100 ms | **+20 %** | +| `fix/93` + phase 5 | 539 / 533 | 590 / 591 | **+51 ms** | **+9 %** | + +PyQt5 is essentially unchanged by phase 5 (the new int constants are inert on PyQt5 — Qt5 enums are already plain ints). PyQt6 dropped another ~20 ms median (mean −5 %): the Python `enum.Flag.__and__` budget is gone for the painter-command State branches (~3 600 enum ops/run eliminated), and the tuple-key font cache replaces the ~6 400 `QFont.key()` calls/run that previously cost ~45 ms. + +**Cumulative speed-ups on the micro load test, vs `master`:** + +| Binding | master → end of phase 4 | end of phase 4 → +phase 5 | **Total** | +|---|--:|--:|--:| +| PyQt5 | −36 % | +5 % (noise) | **−33 %** | +| PyQt6 | −39 % | −3 % | **−41 %** | + +**The PyQt6↔PyQt5 ratio more than halved** (+20 % → +9 %). The remaining +9 % is the structural sip-dispatch cost (PyQt6 marshalling for cheap getters like `drawLine`, `boundingRect`, attribute reads) that is *not* removable from PythonQwt — it can only be mitigated by calling Qt fewer times per render, which phases 1–5 already pursue aggressively. + +## Final results + +> Numbers below summarise the state at the end of phase 4 (the version covered by the Option A gate). Phase 5 was applied on top and further closes the residual Qt5↔Qt6 gap on the micro load test from +20 % to +9 % — see the dedicated phase-5 table above. PlotPy load test was not re-run after phase 5; phase 5 is targeted at the per-call enum/sip overhead that dominates the *micro* benchmark, so the PlotPy improvement is expected to be smaller in relative terms but in the same direction. + +### PythonQwt micro `test_loadtest` (5 runs each, ms) + +| Binding | master | fix/93 (Option A) | Speedup | +|---|---:|---:|---:| +| PyQt5 | ~1 900 | ~450–550 | ×3.5–×4.2 | +| PyQt6 | ~2 300 | ~450–675 | ×3.4–×5.1 | +| PySide6 | ~2 900 | ~580–795 | ×3.6–×5.0 | + +### PlotPy `test_loadtest`, 60 plots (3 runs each, ms) + +| Binding | master (`1ab70cd`) | fix/93 (Option A) | Speedup | +|---|---:|---:|---:| +| PyQt5 | 25 134 | **16 169** | ×1.55 | +| PyQt6 | 42 202 | **21 387** | ×1.97 | +| PySide6 | 53 160 | **24 849** | ×2.14 | + +### Cross-binding gap (PlotPy load test) + +| Comparison | master | fix/93 | +|---|---:|---:| +| PyQt6 vs PyQt5 | +68 % slower | **+32 % slower** | +| PySide6 vs PyQt5 | +111 % slower | **+54 % slower** | + +The original issue — a 1.5×–2× penalty for Qt6 over Qt5 — is largely resolved on the PlotPy load test, while the PyQt5 path remains bit-compatible with master both visually and behaviourally. + +## Backwards compatibility & public API surface + +The optimizations are deliberately confined to internal hot paths and do not alter the documented public API: + +- `QwtScaleMap.transform()`, `QwtScaleDiv.contains()`, `QwtScaleEngine.contains()/strip()`, `QwtAbstractScaleDraw.labelRect()/labelPosition()` — same signatures, same semantics, same return values. +- `QwtText` and `QwtPlainTextEngine` — same signatures and semantics. The internal `_PrivateData` containers no longer derive from `QObject`; this is invisible from the outside because `_PrivateData` was a private holder, never exposed and never used as a Qt signal/slot target. +- New module-level helper `qwt.text.font_key_cached()` is internal (lowercase, undocumented). It can be safely removed or refactored later without breaking any public consumer. +- No new dependency. No change to `qtpy` requirements; the Qt-version gate uses `qtpy.QT_VERSION` which is already imported transitively. + +The screenshot regression sweep above is the empirical confirmation of this: byte-identical PNGs on every non-flaky test mean PythonQwt's rendered output is unchanged, on every binding. + +## Reproduction quickstart + +The whole evaluation can be reproduced from a fresh checkout in a few commands. The scripts assume three sibling virtual environments under `.venvs/{pyqt5,pyqt6,pyside6}/`, each with a single Qt binding plus `numpy`, `qtpy`, `pytest`, `pillow`, and `PythonQwt` installed editable. + +```powershell +# 1. PythonQwt micro load test, all three bindings, 5 runs each +.\scripts\bench_qt.ps1 -Repeat 5 + +# 2. Visual regression sweep (PyQt5 example; repeat for pyqt6 / pyside6) +$env:QT_API = "pyqt5" +& .\.venvs\pyqt5\Scripts\python.exe scripts\capture_screenshots.py shots\fix\pyqt5 +& .\.venvs\pyqt5\Scripts\python.exe scripts\capture_screenshots.py shots\master\pyqt5 # after `git checkout master` +& .\.venvs\pyside6\Scripts\python.exe scripts\diff_screenshots.py shots\master\pyqt5 shots\fix\pyqt5 + +# 3. PlotPy load test (the test cited in the original GitHub issue) +$env:PYTHONPATH = "c:\Dev\PlotPy;c:\Dev\guidata" +foreach ($b in "pyqt5","pyqt6","pyside6") { + & ".\.venvs\$b\Scripts\python.exe" scripts\bench_plotpy_loadtest.py --repeat 3 --nplots 60 +} +``` + +## Test environment + +| Component | Value | +|---|---| +| OS | Windows 11 (x64) | +| Python | 3.11.9 (NuGet build) | +| PyQt5 | 5.15.11 (Qt 5.15.2) | +| PyQt6 | 6.11.0 (Qt 6.11.0) | +| PySide6 | 6.11.0 (Qt 6.11.0) | +| qtpy | latest available at the time of capture | +| PlotPy (for PlotPy load test) | 2.9.1 (editable install from `c:\Dev\PlotPy`) | +| guidata (for PlotPy load test) | 3.14.3 (editable install from `c:\Dev\guidata`) | +| Display | physical desktop session (not `offscreen`) — measurements include real Qt paint/composite cost | + +## Files touched + +| File | Phase 1 (cProfile) | Phase 2 (line-profiler) | Phase 4 (Option A) | Phase 5 (Qt5↔Qt6 gap) | +|---|:-:|:-:|:-:|:-:| +| `qwt/scale_map.py` | ✓ | | | | +| `qwt/scale_div.py` | ✓ | | | | +| `qwt/scale_engine.py` | ✓ | | | | +| `qwt/scale_draw.py` | ✓ | ✓ (drop QObject, `__slots__`) | | | +| `qwt/text.py` | ✓ | ✓ (drop QObject, font cache) | ✓ (Qt5 gate) | ✓ (alignment ints, tuple-key font cache, drop Qt5 gate) | +| `qwt/painter_command.py` | | | | ✓ (int-flag State branch, `_flag_int` helper) | +| `qwt/graphic.py` | | | | ✓ (int-flag State-replay branch) | + +Tooling added under `scripts/`: + +- `bench_qt.ps1` — driver for the PythonQwt micro load test across the three venvs. +- `profile_loadtest.py` — cProfile harness used in phase 1. +- `lineprofile_loadtest.py` — line_profiler harness used in phase 2. +- `capture_screenshots.py` / `diff_screenshots.py` — phase 3 visual regression tooling. +- `bench_plotpy_loadtest.py` — driver for the PlotPy load test (the test cited in the original issue). diff --git a/doc/overview.rst b/doc/overview.rst new file mode 100644 index 0000000..63c28d4 --- /dev/null +++ b/doc/overview.rst @@ -0,0 +1,81 @@ +Purpose and Motivation +====================== + +The ``PythonQwt`` project was initiated to solve -at least temporarily- +the obsolescence issue of `PyQwt` (the Python-Qwt C++ bindings library) +which is no longer maintained. The idea was to translate the original +Qwt C++ code to Python and then to optimize some parts of the code by +writing new modules based on NumPy and other libraries. + +Overview +======== + +The ``PythonQwt`` package consists of a single Python package named +`qwt` and of a few other files (examples, doc, ...): + + - The subpackage `qwt.tests` contains the PythonQwt unit tests: + + - 75% were directly adapted from Qwt/C++ demos (Bode demo, cartesian demo, etc.). + + - 25% were written specifically for PythonQwt. + + - The test launcher is an exclusive PythonQwt feature. + +The `qwt` package is a pure Python implementation of `Qwt` C++ library +with the following limitations. + +The following `Qwt` classes won't be reimplemented in `qwt` because more +powerful features already exist in `PlotPy`: +`QwtPlotZoomer`, +`QwtCounter`, `QwtEventPattern`, `QwtPicker`, `QwtPlotPicker`. + +Only the following plot items are currently implemented in `qwt` (the +only plot items needed by `PlotPy`): `QwtPlotItem` (base class), +`QwtPlotGrid`, `QwtPlotMarker`, `QwtPlotSeriesItem` and `QwtPlotCurve`. + +The `HistogramItem` object implemented in PyQwt's HistogramDemo.py is not +available here (a similar item is already implemented in `PlotPy`). As a +consequence, the following classes are not implemented: `QwtPlotHistogram`, +`QwtIntervalSeriesData`, `QwtIntervalSample`. + +The following data structure objects are not implemented as they seemed +irrelevant with Python and NumPy: `QwtCPointerData` (as a consequence, method +`QwtPlot.setRawSamples` is not implemented), `QwtSyntheticPointData`. + +The following sample data type objects are not implemented as they seemed +quite specific: `QwtSetSample`, `QwtOHLCSample`. For similar reasons, the +`QwtPointPolar` class and the following sample iterator objects are not +implemented: `QwtSetSeriesData`, `QwtTradingChartData` and `QwtPoint3DSeriesData`. + +The following classes are not implemented because they seem inappropriate in +the Python/NumPy context: `QwtArraySeriesData`, `QwtPointSeriesData`, +`QwtAbstractSeriesStore`. + +Threads: + + - Multiple threads for graphic rendering is implemented in Qwt C++ code + thanks to the `QtConcurrent` and `QFuture` Qt features which are + currently not supported by PyQt. + + - As a consequence the following API is not supported in `PythonQwt`: + - `QwtPlotItem.renderThreadCount` + - `QwtPlotItem.setRenderThreadCount` + - option `numThreads` in `QwtPointMapper.toImage` + +The `QwtClipper` class is not implemented yet (and it will probably be +very difficult or even impossible to implement it in pure Python without +performance issues). As a consequence, when zooming in a plot curve, the +entire curve is still painted (in other words, when working with large +amount of data, there is no performance gain when zooming in). + +The curve fitter feature is not implemented because powerful curve fitting +features are already implemented in `PlotPy`. + +Other API compatibility issues with `Qwt`: + + - `QwtPlotCurve.MinimizeMemory` option was removed as this option has no + sense in PythonQwt (the polyline plotting is not taking more memory + than the array data that is already there). + + - `QwtPlotCurve.Fitted` option was removed as this option is not supported + at the moment. diff --git a/doc/plot_example.py b/doc/plot_example.py new file mode 100644 index 0000000..2385e10 --- /dev/null +++ b/doc/plot_example.py @@ -0,0 +1,19 @@ +import os.path as osp + +import numpy as np +from qtpy import QtWidgets as QW + +import qwt +from qwt import qthelpers as qth + +app = QW.QApplication([]) +x = np.linspace(-10, 10, 500) +plot = qwt.QwtPlot("Trigonometric functions") +plot.insertLegend(qwt.QwtLegend(), qwt.QwtPlot.BottomLegend) +qwt.QwtPlotCurve.make(x, np.cos(x), "Cosine", plot, linecolor="red", antialiased=True) +qwt.QwtPlotCurve.make(x, np.sin(x), "Sine", plot, linecolor="blue", antialiased=True) +qth.take_screenshot( + plot, + osp.join(osp.abspath(osp.dirname(__file__)), "_static", "QwtPlot_example.png"), + size=(600, 300), +) diff --git a/doc/reference/graphic.rst b/doc/reference/graphic.rst new file mode 100644 index 0000000..700cbda --- /dev/null +++ b/doc/reference/graphic.rst @@ -0,0 +1 @@ +.. automodule:: qwt.graphic diff --git a/doc/reference/index.rst b/doc/reference/index.rst new file mode 100644 index 0000000..95d8ff3 --- /dev/null +++ b/doc/reference/index.rst @@ -0,0 +1,25 @@ +Reference +========= + +Public API: + +.. toctree:: + :maxdepth: 2 + + plot + scale + symbol + text + toqimage + +Private API: + +.. toctree:: + :maxdepth: 2 + + graphic + interval + plot_directpainter + plot_layout + plot_series + transform diff --git a/doc/reference/interval.rst b/doc/reference/interval.rst new file mode 100644 index 0000000..1121d29 --- /dev/null +++ b/doc/reference/interval.rst @@ -0,0 +1 @@ +.. automodule:: qwt.interval diff --git a/doc/reference/plot.rst b/doc/reference/plot.rst new file mode 100644 index 0000000..5513806 --- /dev/null +++ b/doc/reference/plot.rst @@ -0,0 +1,24 @@ +Plot widget fundamentals +------------------------ + +.. automodule:: qwt.plot + +.. automodule:: qwt.plot_canvas + +Plot items +---------- + +.. automodule:: qwt.plot_grid + +.. automodule:: qwt.plot_curve + +.. automodule:: qwt.plot_marker + +Additional plot features +------------------------ + +.. automodule:: qwt.legend + +.. automodule:: qwt.color_map + +.. automodule:: qwt.plot_renderer diff --git a/doc/reference/plot_directpainter.rst b/doc/reference/plot_directpainter.rst new file mode 100644 index 0000000..f2af03d --- /dev/null +++ b/doc/reference/plot_directpainter.rst @@ -0,0 +1 @@ +.. automodule:: qwt.plot_directpainter diff --git a/doc/reference/plot_layout.rst b/doc/reference/plot_layout.rst new file mode 100644 index 0000000..3600cf3 --- /dev/null +++ b/doc/reference/plot_layout.rst @@ -0,0 +1 @@ +.. automodule:: qwt.plot_layout diff --git a/doc/reference/plot_series.rst b/doc/reference/plot_series.rst new file mode 100644 index 0000000..487eb5c --- /dev/null +++ b/doc/reference/plot_series.rst @@ -0,0 +1 @@ +.. automodule:: qwt.plot_series diff --git a/doc/reference/scale.rst b/doc/reference/scale.rst new file mode 100644 index 0000000..161e5ea --- /dev/null +++ b/doc/reference/scale.rst @@ -0,0 +1,12 @@ +Scales +------ + +.. automodule:: qwt.scale_map + +.. automodule:: qwt.scale_widget + +.. automodule:: qwt.scale_div + +.. automodule:: qwt.scale_engine + +.. automodule:: qwt.scale_draw diff --git a/doc/reference/symbol.rst b/doc/reference/symbol.rst new file mode 100644 index 0000000..2fdd25a --- /dev/null +++ b/doc/reference/symbol.rst @@ -0,0 +1 @@ +.. automodule:: qwt.symbol diff --git a/doc/reference/text.rst b/doc/reference/text.rst new file mode 100644 index 0000000..17b032e --- /dev/null +++ b/doc/reference/text.rst @@ -0,0 +1 @@ +.. automodule:: qwt.text diff --git a/doc/reference/toqimage.rst b/doc/reference/toqimage.rst new file mode 100644 index 0000000..b614ce9 --- /dev/null +++ b/doc/reference/toqimage.rst @@ -0,0 +1 @@ +.. automodule:: qwt.toqimage diff --git a/doc/reference/transform.rst b/doc/reference/transform.rst new file mode 100644 index 0000000..38411bc --- /dev/null +++ b/doc/reference/transform.rst @@ -0,0 +1 @@ +.. automodule:: qwt.transform diff --git a/doc/symbol_path_example.py b/doc/symbol_path_example.py new file mode 100644 index 0000000..070e139 --- /dev/null +++ b/doc/symbol_path_example.py @@ -0,0 +1,55 @@ +import os.path as osp + +import numpy as np +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +import qwt +from qwt import qthelpers as qth + +app = QW.QApplication([]) + +# --- Construct custom symbol --- + +path = QG.QPainterPath() +path.moveTo(0, 8) +path.lineTo(0, 5) +path.lineTo(-3, 5) +path.lineTo(0, 0) +path.lineTo(3, 5) +path.lineTo(0, 5) + +transform = QG.QTransform() +transform.rotate(-30.0) +path = transform.map(path) + +pen = QG.QPen(QC.Qt.black, 2) +pen.setJoinStyle(QC.Qt.MiterJoin) + +symbol = qwt.QwtSymbol() +symbol.setPen(pen) +symbol.setBrush(QC.Qt.red) +symbol.setPath(path) +symbol.setPinPoint(QC.QPointF(0.0, 0.0)) +symbol.setSize(10, 14) + +# --- Test it within a simple plot --- + +curve = qwt.QwtPlotCurve() +curve_pen = QG.QPen(QC.Qt.blue) +curve_pen.setStyle(QC.Qt.DotLine) +curve.setPen(curve_pen) +curve.setSymbol(symbol) +x = np.linspace(0, 10, 10) +curve.setData(x, np.sin(x)) + +plot = qwt.QwtPlot() +curve.attach(plot) +plot.replot() + +qth.take_screenshot( + plot, + osp.join(osp.abspath(osp.dirname(__file__)), "_static", "symbol_path_example.png"), + size=(600, 300), +) diff --git a/examples/BodeDemo.py b/examples/BodeDemo.py deleted file mode 100644 index be1787b..0000000 --- a/examples/BodeDemo.py +++ /dev/null @@ -1,303 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the PyQwt License -# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) -# (see LICENSE file for more details) - -# The Python version of Qwt-5.1.1/examples/bode - -# To get an impression of the expressive power of NumPy, -# compare the Python and C++ versions of setDamp() - -# BodeDemo.py requires at least Python v2.6. -from __future__ import unicode_literals - -import sys -import numpy as np - -from qwt.qt.QtGui import (QApplication, QPen, QBrush, QFrame, QFont, QWidget, - QMainWindow, QToolButton, QIcon, QPixmap, QToolBar, - QHBoxLayout, QLabel, QPrinter, QPrintDialog, - QFontDatabase) -from qwt.qt.QtCore import QSize -from qwt.qt.QtCore import Qt -from qwt import (QwtPlot, QwtPlotMarker, QwtSymbol, QwtLegend, QwtPlotGrid, - QwtPlotCurve, QwtPlotItem, QwtLogScaleEngine, QwtText, - QwtPlotRenderer) - - -print_xpm = ['32 32 12 1', - 'a c #ffffff', - 'h c #ffff00', - 'c c #ffffff', - 'f c #dcdcdc', - 'b c #c0c0c0', - 'j c #a0a0a4', - 'e c #808080', - 'g c #808000', - 'd c #585858', - 'i c #00ff00', - '# c #000000', - '. c None', - '................................', - '................................', - '...........###..................', - '..........#abb###...............', - '.........#aabbbbb###............', - '.........#ddaaabbbbb###.........', - '........#ddddddaaabbbbb###......', - '.......#deffddddddaaabbbbb###...', - '......#deaaabbbddddddaaabbbbb###', - '.....#deaaaaaaabbbddddddaaabbbb#', - '....#deaaabbbaaaa#ddedddfggaaad#', - '...#deaaaaaaaaaa#ddeeeeafgggfdd#', - '..#deaaabbbaaaa#ddeeeeabbbbgfdd#', - '.#deeefaaaaaaa#ddeeeeabbhhbbadd#', - '#aabbbeeefaaa#ddeeeeabbbbbbaddd#', - '#bbaaabbbeee#ddeeeeabbiibbadddd#', - '#bbbbbaaabbbeeeeeeabbbbbbaddddd#', - '#bjbbbbbbaaabbbbeabbbbbbadddddd#', - '#bjjjjbbbbbbaaaeabbbbbbaddddddd#', - '#bjaaajjjbbbbbbaaabbbbadddddddd#', - '#bbbbbaaajjjbbbbbbaaaaddddddddd#', - '#bjbbbbbbaaajjjbbbbbbddddddddd#.', - '#bjjjjbbbbbbaaajjjbbbdddddddd#..', - '#bjaaajjjbbbbbbjaajjbddddddd#...', - '#bbbbbaaajjjbbbjbbaabdddddd#....', - '###bbbbbbaaajjjjbbbbbddddd#.....', - '...###bbbbbbaaajbbbbbdddd#......', - '......###bbbbbbjbbbbbddd#.......', - '.........###bbbbbbbbbdd#........', - '............###bbbbbbd#.........', - '...............###bbb#..........', - '..................###...........'] - - -class BodePlot(QwtPlot): - - def __init__(self, *args): - QwtPlot.__init__(self, *args) - - self.setTitle('Frequency Response of a 2nd-order System') - self.setCanvasBackground(Qt.darkBlue) - - # legend - legend = QwtLegend() - legend.setFrameStyle(QFrame.Box | QFrame.Sunken) - self.insertLegend(legend, QwtPlot.BottomLegend) - - # grid - self.grid = QwtPlotGrid() - self.grid.enableXMin(True) - self.grid.attach(self) - - # axes - self.enableAxis(QwtPlot.yRight) - self.setAxisTitle(QwtPlot.xBottom, '\u03c9/\u03c90') - self.setAxisTitle(QwtPlot.yLeft, 'Amplitude [dB]') - self.setAxisTitle(QwtPlot.yRight, 'Phase [\u00b0]') - - self.setAxisMaxMajor(QwtPlot.xBottom, 6) - self.setAxisMaxMinor(QwtPlot.xBottom, 10) - self.setAxisScaleEngine(QwtPlot.xBottom, QwtLogScaleEngine()) - - # curves - self.curve1 = QwtPlotCurve('Amplitude') - self.curve1.setRenderHint(QwtPlotItem.RenderAntialiased); - self.curve1.setPen(QPen(Qt.yellow)) - self.curve1.setYAxis(QwtPlot.yLeft) - self.curve1.attach(self) - - self.curve2 = QwtPlotCurve('Phase') - self.curve2.setRenderHint(QwtPlotItem.RenderAntialiased); - self.curve2.setPen(QPen(Qt.cyan)) - self.curve2.setYAxis(QwtPlot.yRight) - self.curve2.attach(self) - - # alias - fn = self.fontInfo().family() - - # marker - self.dB3Marker = m = QwtPlotMarker() - m.setValue(0.0, 0.0) - m.setLineStyle(QwtPlotMarker.VLine) - m.setLabelAlignment(Qt.AlignRight | Qt.AlignBottom) - m.setLinePen(QPen(Qt.green, 2, Qt.DashDotLine)) - text = QwtText('') - text.setColor(Qt.green) - text.setBackgroundBrush(Qt.red) - text.setFont(QFont(fn, 12, QFont.Bold)) - m.setLabel(text) - m.attach(self) - - self.peakMarker = m = QwtPlotMarker() - m.setLineStyle(QwtPlotMarker.HLine) - m.setLabelAlignment(Qt.AlignRight | Qt.AlignBottom) - m.setLinePen(QPen(Qt.red, 2, Qt.DashDotLine)) - text = QwtText('') - text.setColor(Qt.red) - text.setBackgroundBrush(QBrush(self.canvasBackground())) - text.setFont(QFont(fn, 12, QFont.Bold)) - - m.setLabel(text) - m.setSymbol(QwtSymbol(QwtSymbol.Diamond, - QBrush(Qt.yellow), - QPen(Qt.green), - QSize(7,7))) - m.attach(self) - - # text marker - m = QwtPlotMarker() - m.setValue(0.1, -20.0) - m.setLabelAlignment(Qt.AlignRight | Qt.AlignBottom) - text = QwtText( - '[1-(\u03c9/\u03c90)2+2j\u03c9/Q]' - '-1' - ) - text.setFont(QFont(fn, 12, QFont.Bold)) - text.setColor(Qt.blue) - text.setBackgroundBrush(QBrush(Qt.yellow)) - text.setBorderPen(QPen(Qt.red, 2)) - m.setLabel(text) - m.attach(self) - - self.setDamp(0.01) - - def showData(self, frequency, amplitude, phase): - self.curve1.setData(frequency, amplitude) - self.curve2.setData(frequency, phase) - - def showPeak(self, frequency, amplitude): - self.peakMarker.setValue(frequency, amplitude) - label = self.peakMarker.label() - label.setText('Peak: %4g dB' % amplitude) - self.peakMarker.setLabel(label) - - def show3dB(self, frequency): - self.dB3Marker.setValue(frequency, 0.0) - label = self.dB3Marker.label() - label.setText('-3dB at f = %4g' % frequency) - self.dB3Marker.setLabel(label) - - def setDamp(self, d): - self.damping = d - # Numerical Python: f, g, a and p are NumPy arrays! - f = np.exp(np.log(10.0)*np.arange(-2, 2.02, 0.04)) - g = 1.0/(1.0-f*f+2j*self.damping*f) - a = 20.0*np.log10(abs(g)) - p = 180*np.arctan2(g.imag, g.real)/np.pi - # for show3dB - i3 = np.argmax(np.where(np.less(a, -3.0), a, -100.0)) - f3 = f[i3] - (a[i3]+3.0)*(f[i3]-f[i3-1])/(a[i3]-a[i3-1]) - # for showPeak - imax = np.argmax(a) - - self.showPeak(f[imax], a[imax]) - self.show3dB(f3) - self.showData(f, a, p) - - self.replot() - - -class BodeDemo(QMainWindow): - - def __init__(self, *args): - QMainWindow.__init__(self, *args) - - self.plot = BodePlot(self) - self.plot.setContentsMargins(5, 5, 5, 0) - - self.setContextMenuPolicy(Qt.NoContextMenu) - - self.setCentralWidget(self.plot) - - toolBar = QToolBar(self) - self.addToolBar(toolBar) - - btnPrint = QToolButton(toolBar) - btnPrint.setText("Print") - btnPrint.setIcon(QIcon(QPixmap(print_xpm))) - btnPrint.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) - toolBar.addWidget(btnPrint) - btnPrint.clicked.connect(self.print_) - - btnExport = QToolButton(toolBar) - btnExport.setText("Export") - btnExport.setIcon(QIcon(QPixmap(print_xpm))) - btnExport.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) - toolBar.addWidget(btnExport) - btnExport.clicked.connect(self.exportDocument) - - toolBar.addSeparator() - - dampBox = QWidget(toolBar) - dampLayout = QHBoxLayout(dampBox) - dampLayout.setSpacing(0) - dampLayout.addWidget(QWidget(dampBox), 10) # spacer - dampLayout.addWidget(QLabel("Damping Factor", dampBox), 0) - dampLayout.addSpacing(10) - - toolBar.addWidget(dampBox) - - self.statusBar() - - self.showInfo() - - def print_(self): - printer = QPrinter(QPrinter.HighResolution) - - printer.setCreator('Bode example') - printer.setOrientation(QPrinter.Landscape) - printer.setColorMode(QPrinter.Color) - - docName = str(self.plot.title().text()) - if not docName: - docName.replace('\n', ' -- ') - printer.setDocName(docName) - - dialog = QPrintDialog(printer) - if dialog.exec_(): - renderer = QwtPlotRenderer() - if (QPrinter.GrayScale == printer.colorMode()): - renderer.setDiscardFlag(QwtPlotRenderer.DiscardBackground) - renderer.setDiscardFlag(QwtPlotRenderer.DiscardCanvasBackground) - renderer.setDiscardFlag(QwtPlotRenderer.DiscardCanvasFrame) - renderer.setLayoutFlag(QwtPlotRenderer.FrameWithScales) - renderer.renderTo(self.plot, printer) - - def exportDocument(self): - renderer = QwtPlotRenderer(self.plot) - renderer.exportTo(self.plot, "bode") - - def showInfo(self, text=""): - self.statusBar().showMessage(text) - - def moved(self, point): - info = "Freq=%g, Ampl=%g, Phase=%g" % ( - self.plot.invTransform(QwtPlot.xBottom, point.x()), - self.plot.invTransform(QwtPlot.yLeft, point.y()), - self.plot.invTransform(QwtPlot.yRight, point.y())) - self.showInfo(info) - - def selected(self, _): - self.showInfo() - - -def make(): - demo = BodeDemo() - demo.resize(540, 400) - demo.show() - return demo - - -if __name__ == '__main__': - app = QApplication(sys.argv) - fonts = QFontDatabase() - for name in ('Verdana', 'STIXGeneral'): - if name in fonts.families(): - app.setFont(QFont(name)) - break - demo = make() - sys.exit(app.exec_()) diff --git a/examples/CPUplot.py b/examples/CPUplot.py deleted file mode 100644 index 177eed2..0000000 --- a/examples/CPUplot.py +++ /dev/null @@ -1,397 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the PyQwt License -# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) -# (see LICENSE file for more details) - -import os -import sys -import numpy as np - -from qwt.qt.QtGui import (QApplication, QColor, QBrush, QWidget, QVBoxLayout, - QLabel) -from qwt.qt.QtCore import QRect, QTime -from qwt.qt.QtCore import Qt -from qwt import (QwtPlot, QwtPlotMarker, QwtScaleDraw, QwtLegend, QwtPlotCurve, - QwtPlotItem, QwtLegendData, QwtText) - - -class CpuStat: - User = 0 - Nice = 1 - System = 2 - Idle = 3 - counter = 0 - dummyValues = ( - ( 103726, 0, 23484, 819556 ), - ( 103783, 0, 23489, 819604 ), - ( 103798, 0, 23490, 819688 ), - ( 103820, 0, 23490, 819766 ), - ( 103840, 0, 23493, 819843 ), - ( 103875, 0, 23499, 819902 ), - ( 103917, 0, 23504, 819955 ), - ( 103950, 0, 23508, 820018 ), - ( 103987, 0, 23510, 820079 ), - ( 104020, 0, 23513, 820143 ), - ( 104058, 0, 23514, 820204 ), - ( 104099, 0, 23520, 820257 ), - ( 104121, 0, 23525, 820330 ), - ( 104159, 0, 23530, 820387 ), - ( 104176, 0, 23534, 820466 ), - ( 104215, 0, 23538, 820523 ), - ( 104245, 0, 23541, 820590 ), - ( 104267, 0, 23545, 820664 ), - ( 104311, 0, 23555, 820710 ), - ( 104355, 0, 23565, 820756 ), - ( 104367, 0, 23567, 820842 ), - ( 104383, 0, 23572, 820921 ), - ( 104396, 0, 23577, 821003 ), - ( 104413, 0, 23579, 821084 ), - ( 104446, 0, 23588, 821142 ), - ( 104521, 0, 23594, 821161 ), - ( 104611, 0, 23604, 821161 ), - ( 104708, 0, 23607, 821161 ), - ( 104804, 0, 23611, 821161 ), - ( 104895, 0, 23620, 821161 ), - ( 104993, 0, 23622, 821161 ), - ( 105089, 0, 23626, 821161 ), - ( 105185, 0, 23630, 821161 ), - ( 105281, 0, 23634, 821161 ), - ( 105379, 0, 23636, 821161 ), - ( 105472, 0, 23643, 821161 ), - ( 105569, 0, 23646, 821161 ), - ( 105666, 0, 23649, 821161 ), - ( 105763, 0, 23652, 821161 ), - ( 105828, 0, 23661, 821187 ), - ( 105904, 0, 23666, 821206 ), - ( 105999, 0, 23671, 821206 ), - ( 106094, 0, 23676, 821206 ), - ( 106184, 0, 23686, 821206 ), - ( 106273, 0, 23692, 821211 ), - ( 106306, 0, 23700, 821270 ), - ( 106341, 0, 23703, 821332 ), - ( 106392, 0, 23709, 821375 ), - ( 106423, 0, 23715, 821438 ), - ( 106472, 0, 23721, 821483 ), - ( 106531, 0, 23727, 821517 ), - ( 106562, 0, 23732, 821582 ), - ( 106597, 0, 23736, 821643 ), - ( 106633, 0, 23737, 821706 ), - ( 106666, 0, 23742, 821768 ), - ( 106697, 0, 23744, 821835 ), - ( 106730, 0, 23748, 821898 ), - ( 106765, 0, 23751, 821960 ), - ( 106799, 0, 23754, 822023 ), - ( 106831, 0, 23758, 822087 ), - ( 106862, 0, 23761, 822153 ), - ( 106899, 0, 23763, 822214 ), - ( 106932, 0, 23766, 822278 ), - ( 106965, 0, 23768, 822343 ), - ( 107009, 0, 23771, 822396 ), - ( 107040, 0, 23775, 822461 ), - ( 107092, 0, 23780, 822504 ), - ( 107143, 0, 23787, 822546 ), - ( 107200, 0, 23795, 822581 ), - ( 107250, 0, 23803, 822623 ), - ( 107277, 0, 23810, 822689 ), - ( 107286, 0, 23810, 822780 ), - ( 107313, 0, 23817, 822846 ), - ( 107325, 0, 23818, 822933 ), - ( 107332, 0, 23818, 823026 ), - ( 107344, 0, 23821, 823111 ), - ( 107357, 0, 23821, 823198 ), - ( 107368, 0, 23823, 823284 ), - ( 107375, 0, 23824, 823377 ), - ( 107386, 0, 23825, 823465 ), - ( 107396, 0, 23826, 823554 ), - ( 107422, 0, 23830, 823624 ), - ( 107434, 0, 23831, 823711 ), - ( 107456, 0, 23835, 823785 ), - ( 107468, 0, 23838, 823870 ), - ( 107487, 0, 23840, 823949 ), - ( 107515, 0, 23843, 824018 ), - ( 107528, 0, 23846, 824102 ), - ( 107535, 0, 23851, 824190 ), - ( 107548, 0, 23853, 824275 ), - ( 107562, 0, 23857, 824357 ), - ( 107656, 0, 23863, 824357 ), - ( 107751, 0, 23868, 824357 ), - ( 107849, 0, 23870, 824357 ), - ( 107944, 0, 23875, 824357 ), - ( 108043, 0, 23876, 824357 ), - ( 108137, 0, 23882, 824357 ), - ( 108230, 0, 23889, 824357 ), - ( 108317, 0, 23902, 824357 ), - ( 108412, 0, 23907, 824357 ), - ( 108511, 0, 23908, 824357 ), - ( 108608, 0, 23911, 824357 ), - ( 108704, 0, 23915, 824357 ), - ( 108801, 0, 23918, 824357 ), - ( 108891, 0, 23928, 824357 ), - ( 108987, 0, 23932, 824357 ), - ( 109072, 0, 23943, 824361 ), - ( 109079, 0, 23943, 824454 ), - ( 109086, 0, 23944, 824546 ), - ( 109098, 0, 23950, 824628 ), - ( 109108, 0, 23955, 824713 ), - ( 109115, 0, 23957, 824804 ), - ( 109122, 0, 23958, 824896 ), - ( 109132, 0, 23959, 824985 ), - ( 109142, 0, 23961, 825073 ), - ( 109146, 0, 23962, 825168 ), - ( 109153, 0, 23964, 825259 ), - ( 109162, 0, 23966, 825348 ), - ( 109168, 0, 23969, 825439 ), - ( 109176, 0, 23971, 825529 ), - ( 109185, 0, 23974, 825617 ), - ( 109193, 0, 23977, 825706 ), - ( 109198, 0, 23978, 825800 ), - ( 109206, 0, 23978, 825892 ), - ( 109212, 0, 23981, 825983 ), - ( 109219, 0, 23981, 826076 ), - ( 109225, 0, 23981, 826170 ), - ( 109232, 0, 23984, 826260 ), - ( 109242, 0, 23984, 826350 ), - ( 109255, 0, 23986, 826435 ), - ( 109268, 0, 23987, 826521 ), - ( 109283, 0, 23990, 826603 ), - ( 109288, 0, 23991, 826697 ), - ( 109295, 0, 23993, 826788 ), - ( 109308, 0, 23994, 826874 ), - ( 109322, 0, 24009, 826945 ), - ( 109328, 0, 24011, 827037 ), - ( 109338, 0, 24012, 827126 ), - ( 109347, 0, 24012, 827217 ), - ( 109354, 0, 24017, 827305 ), - ( 109367, 0, 24017, 827392 ), - ( 109371, 0, 24019, 827486 ), - ) - - def __init__(self): - self.procValues = self.__lookup() - - def statistic(self): - values = self.__lookup() - userDelta = 0.0 - for i in [CpuStat.User, CpuStat.Nice]: - userDelta += (values[i] - self.procValues[i]) - systemDelta = values[CpuStat.System] - self.procValues[CpuStat.System] - totalDelta = 0.0 - for i in range(len(self.procValues)): - totalDelta += (values[i] - self.procValues[i]) - self.procValues = values - return 100.0*userDelta/totalDelta, 100.0*systemDelta/totalDelta - - def upTime(self): - result = QTime() - for item in self.procValues: - result = result.addSecs(item/100) - return result - - def __lookup(self): - if os.path.exists("/proc/stat"): - for line in open("/proc/stat"): - words = line.split() - if words[0] == "cpu" and len(words) >= 5: - return [float(w) for w in words[1:]] - else: - result = CpuStat.dummyValues[CpuStat.counter] - CpuStat.counter += 1 - CpuStat.counter %= len(CpuStat.dummyValues) - return result - - -class CpuPieMarker(QwtPlotMarker): - def __init__(self, *args): - QwtPlotMarker.__init__(self, *args) - self.setZ(1000.0) - self.setRenderHint(QwtPlotItem.RenderAntialiased, True) - - def rtti(self): - return QwtPlotItem.Rtti_PlotUserItem - - def draw(self, painter, xMap, yMap, rect): - margin = 5 - pieRect = QRect() - pieRect.setX(rect.x() + margin) - pieRect.setY(rect.y() + margin) - pieRect.setHeight(yMap.transform(80.0)) - pieRect.setWidth(pieRect.height()) - - angle = 3*5760/4 - for key in ["User", "System", "Idle"]: - curve = self.plot().cpuPlotCurve(key) - if curve.dataSize(): - value = int(5760*curve.sample(0).y()/100.0) - painter.save() - painter.setBrush(QBrush(curve.pen().color(), - Qt.SolidPattern)) - painter.drawPie(pieRect, -angle, -value) - painter.restore() - angle += value - - -class TimeScaleDraw(QwtScaleDraw): - def __init__(self, baseTime, *args): - QwtScaleDraw.__init__(self, *args) - self.baseTime = baseTime - - def label(self, value): - upTime = self.baseTime.addSecs(int(value)) - return QwtText(upTime.toString()) - - -class Background(QwtPlotItem): - def __init__(self): - QwtPlotItem.__init__(self) - self.setZ(0.0) - - def rtti(self): - return QwtPlotItem.Rtti_PlotUserItem - - def draw(self, painter, xMap, yMap, rect): - c = QColor(Qt.white) - r = QRect(rect) - - for i in range(100, 0, -10): - r.setBottom(yMap.transform(i - 10)) - r.setTop(yMap.transform(i)) - painter.fillRect(r, c) - c = c.darker(110) - - -class CpuCurve(QwtPlotCurve): - def __init__(self, *args): - QwtPlotCurve.__init__(self, *args) - self.setRenderHint(QwtPlotItem.RenderAntialiased) - - def setColor(self, color): - c = QColor(color) - c.setAlpha(150) - - self.setPen(c) - self.setBrush(c) - - -HISTORY = 60 - -class CpuPlot(QwtPlot): - def __init__(self, *args): - QwtPlot.__init__(self, *args) - - self.curves = {} - self.data = {} - self.timeData = 1.0 * np.arange(HISTORY-1, -1, -1) - self.cpuStat = CpuStat() - - self.setAutoReplot(False) - - self.plotLayout().setAlignCanvasToScales(True) - - legend = QwtLegend() - legend.setDefaultItemMode(QwtLegendData.Checkable) - self.insertLegend(legend, QwtPlot.RightLegend) - - self.setAxisTitle(QwtPlot.xBottom, "System Uptime [h:m:s]") - self.setAxisScaleDraw( - QwtPlot.xBottom, TimeScaleDraw(self.cpuStat.upTime())) - self.setAxisScale(QwtPlot.xBottom, 0, HISTORY) - self.setAxisLabelRotation(QwtPlot.xBottom, -50.0) - self.setAxisLabelAlignment( - QwtPlot.xBottom, Qt.AlignLeft | Qt.AlignBottom) - - self.setAxisTitle(QwtPlot.yLeft, "Cpu Usage [%]") - self.setAxisScale(QwtPlot.yLeft, 0, 100) - - background = Background() - background.attach(self) - - pie = CpuPieMarker() - pie.attach(self) - - curve = CpuCurve('System') - curve.setColor(Qt.red) - curve.attach(self) - self.curves['System'] = curve - self.data['System'] = np.zeros(HISTORY, np.float) - - curve = CpuCurve('User') - curve.setColor(Qt.blue) - curve.setZ(curve.z() - 1.0) - curve.attach(self) - self.curves['User'] = curve - self.data['User'] = np.zeros(HISTORY, np.float) - - curve = CpuCurve('Total') - curve.setColor(Qt.black) - curve.setZ(curve.z() - 2.0) - curve.attach(self) - self.curves['Total'] = curve - self.data['Total'] = np.zeros(HISTORY, np.float) - - curve = CpuCurve('Idle') - curve.setColor(Qt.darkCyan) - curve.setZ(curve.z() - 3.0) - curve.attach(self) - self.curves['Idle'] = curve - self.data['Idle'] = np.zeros(HISTORY, np.float) - - self.showCurve(self.curves['System'], True) - self.showCurve(self.curves['User'], True) - self.showCurve(self.curves['Total'], False) - self.showCurve(self.curves['Idle'], False) - - self.startTimer(1000) - - legend.SIG_CHECKED.connect(self.showCurve) - self.replot() - - def timerEvent(self, e): - for data in self.data.values(): - data[1:] = data[0:-1] - self.data["User"][0], self.data["System"][0] = self.cpuStat.statistic() - self.data["Total"][0] = self.data["User"][0] + self.data["System"][0] - self.data["Idle"][0] = 100.0 - self.data["Total"][0] - - self.timeData += 1.0 - - self.setAxisScale( - QwtPlot.xBottom, self.timeData[-1], self.timeData[0]) - for key in self.curves.keys(): - self.curves[key].setData(self.timeData, self.data[key]) - - self.replot() - - def showCurve(self, item, on, index=None): - item.setVisible(on) - self.legend().legendWidget(item).setChecked(on) - self.replot() - - def cpuPlotCurve(self, key): - return self.curves[key] - - -def make(): - demo = QWidget() - demo.setWindowTitle('Cpu Plot') - - plot = CpuPlot(demo) - plot.setTitle("History") - - label = QLabel("Press the legend to en/disable a curve", demo) - - layout = QVBoxLayout(demo) - layout.addWidget(plot) - layout.addWidget(label) - - demo.resize(600, 400) - demo.show() - return demo - - -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) diff --git a/examples/HistogramDemo.py b/examples/HistogramDemo.py deleted file mode 100644 index ab57dff..0000000 --- a/examples/HistogramDemo.py +++ /dev/null @@ -1,220 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the PyQwt License -# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) -# (see LICENSE file for more details) - -import random -import sys - -from qwt.qt.QtGui import QApplication, QPen, QColor -from qwt.qt.QtCore import QRect -from qwt.qt.QtCore import Qt -from qwt import (QwtPlot, QwtIntervalSample, QwtInterval, QwtPlotGrid, - QwtPlotItem, QwtPainter, QwtIntervalSeriesData) - - -class HistogramItem(QwtPlotItem): - Auto = 0 - Xfy = 1 - def __init__(self, *args): - QwtPlotItem.__init__(self, *args) - self.__attributes = HistogramItem.Auto - self.__data = QwtIntervalSeriesData() - self.__color = QColor() - self.__reference = 0.0 - self.setItemAttribute(QwtPlotItem.AutoScale, True) - self.setItemAttribute(QwtPlotItem.Legend, True) - self.setZ(20.0) - - def setData(self, data): - self.__data = data - self.itemChanged() - - def data(self): - return self.__data - - def setColor(self, color): - if self.__color != color: - self.__color = color - self.itemChanged() - - def color(self): - return self.__color - - def boundingRect(self): - result = self.__data.boundingRect() - if not result.isValid(): - return result - if self.testHistogramAttribute(HistogramItem.Xfy): - result = QwtDoubleRect(result.y(), result.x(), - result.height(), result.width()) - if result.left() > self.baseline(): - result.setLeft(self.baseline()) - elif result.right() < self.baseline(): - result.setRight(self.baseline()) - else: - if result.bottom() < self.baseline(): - result.setBottom(self.baseline()) - elif result.top() > self.baseline(): - result.setTop(self.baseline()) - return result - - def rtti(self): - return QwtPlotItem.PlotHistogram - - def draw(self, painter, xMap, yMap, rect): - iData = self.data() - painter.setPen(self.color()) - x0 = xMap.transform(self.baseline()) - y0 = yMap.transform(self.baseline()) - for i in range(iData.size()): - if self.testHistogramAttribute(HistogramItem.Xfy): - x2 = xMap.transform(iData.sample(i).value) - if x2 == x0: - continue - - y1 = yMap.transform(iData.sample(i).interval.minValue()) - y2 = yMap.transform(iData.sample(i).interval.maxValue()) - - if y1 > y2: - y1, y2 = y2, y1 - - if i < iData.size()-2: - yy1 = yMap.transform(iData.sample(i+1).interval.minValue()) - yy2 = yMap.transform(iData.sample(i+1).interval.maxValue()) - - if y2 == min(yy1, yy2): - xx2 = xMap.transform(iData.sample(i+1).interval.minValue()) - if xx2 != x0 and ((xx2 < x0 and x2 < x0) - or (xx2 > x0 and x2 > x0)): - # One pixel distance between neighboured bars - y2 += 1 - - self.drawBar( - painter, Qt.Horizontal, QRect(x0, y1, x2-x0, y2-y1)) - else: - y2 = yMap.transform(iData.sample(i).value) - if y2 == y0: - continue - - x1 = xMap.transform(iData.sample(i).interval.minValue()) - x2 = xMap.transform(iData.sample(i).interval.maxValue()) - - if x1 > x2: - x1, x2 = x2, x1 - - if i < iData.size()-2: - xx1 = xMap.transform(iData.sample(i+1).interval.minValue()) - xx2 = xMap.transform(iData.sample(i+1).interval.maxValue()) - x2 = min(xx1, xx2) - yy2 = yMap.transform(iData.sample(i+1).value) - if x2 == min(xx1, xx2): - if yy2 != 0 and (( yy2 < y0 and y2 < y0) - or (yy2 > y0 and y2 > y0)): - # One pixel distance between neighboured bars - x2 -= 1 - - self.drawBar( - painter, Qt.Vertical, QRect(x1, y0, x2-x1, y2-y0)) - - def setBaseline(self, reference): - if self.baseline() != reference: - self.__reference = reference - self.itemChanged() - - def baseline(self,): - return self.__reference - - def setHistogramAttribute(self, attribute, on = True): - if self.testHistogramAttribute(attribute): - return - - if on: - self.__attributes |= attribute - else: - self.__attributes &= ~attribute - - self.itemChanged() - - def testHistogramAttribute(self, attribute): - return bool(self.__attributes & attribute) - - def drawBar(self, painter, orientation, rect): - painter.save() - color = painter.pen().color() - r = rect.normalized() - factor = 125; - light = color.lighter(factor) - dark = color.darker(factor) - - painter.setBrush(color) - painter.setPen(Qt.NoPen) - QwtPainter.drawRect(painter, r.x()+1, r.y()+1, - r.width()-2, r.height()-2) - - painter.setBrush(Qt.NoBrush) - - painter.setPen(QPen(light, 2)) - QwtPainter.drawLine( - painter, r.left()+1, r.top()+2, r.right()+1, r.top()+2) - - painter.setPen(QPen(dark, 2)) - QwtPainter.drawLine( - painter, r.left()+1, r.bottom(), r.right()+1, r.bottom()) - - painter.setPen(QPen(light, 1)) - QwtPainter.drawLine( - painter, r.left(), r.top() + 1, r.left(), r.bottom()) - QwtPainter.drawLine( - painter, r.left()+1, r.top()+2, r.left()+1, r.bottom()-1) - - painter.setPen(QPen(dark, 1)) - QwtPainter.drawLine( - painter, r.right()+1, r.top()+1, r.right()+1, r.bottom()) - QwtPainter.drawLine( - painter, r.right(), r.top()+2, r.right(), r.bottom()-1) - - painter.restore() - - -def make(): - demo = QwtPlot() - demo.setCanvasBackground(Qt.white) - demo.setTitle("Histogram") - - grid = QwtPlotGrid() - grid.enableXMin(True) - grid.enableYMin(True) - grid.setMajorPen(QPen(Qt.black, 0, Qt.DotLine)); - grid.setMinorPen(QPen(Qt.gray, 0 , Qt.DotLine)); - grid.attach(demo) - - histogram = HistogramItem() - histogram.setColor(Qt.darkCyan) - - numValues = 20 - samples = [] - pos = 0.0 - for i in range(numValues): - width = 5 + random.randint(0, 4) - value = random.randint(0, 99) - samples.append(QwtIntervalSample(value, QwtInterval(pos, pos+width))); - pos += width - - histogram.setData(QwtIntervalSeriesData(samples)) - histogram.attach(demo) - demo.setAxisScale(QwtPlot.yLeft, 0.0, 100.0) - demo.setAxisScale(QwtPlot.xBottom, 0.0, pos) - demo.replot() - demo.resize(600, 400) - demo.show() - return demo - - -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) diff --git a/examples/ImagePlotDemo.py b/examples/ImagePlotDemo.py deleted file mode 100644 index 1830d08..0000000 --- a/examples/ImagePlotDemo.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the PyQwt License -# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) -# (see LICENSE file for more details) - -import sys -import numpy as np - -from qwt.qt.QtGui import QApplication, QPen, qRgb -from qwt.qt.QtCore import Qt -from qwt import (QwtPlot, QwtPlotMarker, QwtLegend, QwtPlotGrid, QwtPlotCurve, - QwtPlotItem, QwtText, QwtLegendData, QwtLinearColorMap, - QwtInterval, QwtScaleMap, toQImage) - -#FIXME: This example is still not working: I suspect an issue related to image scaling (see PlotImage.draw) - -def bytescale(data, cmin=None, cmax=None, high=255, low=0): - if ((hasattr(data, 'dtype') and data.dtype.char == np.uint8) - or (hasattr(data, 'typecode') and data.typecode == np.uint8) - ): - return data - high = high - low - if cmin is None: - cmin = min(np.ravel(data)) - if cmax is None: - cmax = max(np.ravel(data)) - scale = high * 1.0 / (cmax-cmin or 1) - bytedata = ((data*1.0-cmin)*scale + 0.4999).astype(np.uint8) - return bytedata + np.asarray(low).astype(np.uint8) - -def linearX(nx, ny): - return np.repeat(np.arange(nx, typecode = np.float32)[:, np.newaxis], ny, -1) - -def linearY(nx, ny): - return np.repeat(np.arange(ny, typecode = np.float32)[np.newaxis, :], nx, 0) - -def square(n, min, max): - t = np.arange(min, max, float(max-min)/(n-1)) - #return outer(cos(t), sin(t)) - return np.cos(t)*np.sin(t)[:,np.newaxis] - - -class PlotImage(QwtPlotItem): - def __init__(self, title = QwtText()): - QwtPlotItem.__init__(self) - self.setTitle(title) - self.setItemAttribute(QwtPlotItem.Legend); - self.xyzs = None - - def setData(self, xyzs, xRange = None, yRange = None): - self.xyzs = xyzs - shape = xyzs.shape - if not xRange: - xRange = (0, shape[0]) - if not yRange: - yRange = (0, shape[1]) - - self.xMap = QwtScaleMap(0, xyzs.shape[0], *xRange) - self.plot().setAxisScale(QwtPlot.xBottom, *xRange) - self.yMap = QwtScaleMap(0, xyzs.shape[1], *yRange) - self.plot().setAxisScale(QwtPlot.yLeft, *yRange) - - self.image = toQImage(bytescale(self.xyzs)).mirrored(False, True) - for i in range(0, 256): - self.image.setColor(i, qRgb(i, 0, 255-i)) - - def updateLegend(self, legend): - QwtPlotItem.updateLegend(self, legend) - legend.find(self).setText(self.title()) - - def draw(self, painter, xMap, yMap, rect): - """Paint image zoomed to xMap, yMap - - Calculate (x1, y1, x2, y2) so that it contains at least 1 pixel, - and copy the visible region to scale it to the canvas. - """ - assert(isinstance(self.plot(), QwtPlot)) - - # calculate y1, y2 - # the scanline order (index y) is inverted with respect to the y-axis - y1 = y2 = self.image.height() - y1 *= (self.yMap.s2() - yMap.s2()) - y1 /= (self.yMap.s2() - self.yMap.s1()) - y1 = max(0, int(y1-0.5)) - y2 *= (self.yMap.s2() - yMap.s1()) - y2 /= (self.yMap.s2() - self.yMap.s1()) - y2 = min(self.image.height(), int(y2+0.5)) - # calculate x1, x2 -- the pixel order (index x) is normal - x1 = x2 = self.image.width() - x1 *= (xMap.s1() - self.xMap.s1()) - x1 /= (self.xMap.s2() - self.xMap.s1()) - x1 = max(0, int(x1-0.5)) - x2 *= (xMap.s2() - self.xMap.s1()) - x2 /= (self.xMap.s2() - self.xMap.s1()) - x2 = min(self.image.width(), int(x2+0.5)) - # copy - image = self.image.copy(x1, y1, x2-x1, y2-y1) - # zoom - image = image.scaled(xMap.p2()-xMap.p1()+1, yMap.p1()-yMap.p2()+1) - # draw - painter.drawImage(xMap.p1(), yMap.p2(), image) - - -class ImagePlot(QwtPlot): - def __init__(self, *args): - QwtPlot.__init__(self, *args) - # set plot title - self.setTitle('ImagePlot') - # set plot layout - self.plotLayout().setCanvasMargin(0) - self.plotLayout().setAlignCanvasToScales(True) - # set legend - legend = QwtLegend() - legend.setDefaultItemMode(QwtLegendData.Clickable) - self.insertLegend(legend, QwtPlot.RightLegend) - # set axis titles - self.setAxisTitle(QwtPlot.xBottom, 'time (s)') - self.setAxisTitle(QwtPlot.yLeft, 'frequency (Hz)') - - colorMap = QwtLinearColorMap(Qt.blue, Qt.red) - interval = QwtInterval(-1, 1) - self.enableAxis(QwtPlot.yRight) - self.setAxisScale(QwtPlot.yRight, -1, 1) - self.axisWidget(QwtPlot.yRight).setColorBarEnabled(True) - self.axisWidget(QwtPlot.yRight).setColorMap(interval, colorMap) - - # calculate 3 NumPy arrays - x = np.arange(-2*np.pi, 2*np.pi, 0.01) - y = np.pi*np.sin(x) - z = 4*np.pi*np.cos(x)*np.cos(x)*np.sin(x) - # attach a curve - curve = QwtPlotCurve('y = pi*sin(x)') - curve.attach(self) - curve.setPen(QPen(Qt.green, 2)) - curve.setData(x, y) - # attach another curve - curve = QwtPlotCurve('y = 4*pi*sin(x)*cos(x)**2') - curve.attach(self) - curve.setPen(QPen(Qt.black, 2)) - curve.setData(x, z) - # attach a grid - grid = QwtPlotGrid() - grid.attach(self) - grid.setPen(QPen(Qt.black, 0, Qt.DotLine)) - # attach a horizontal marker at y = 0 - marker = QwtPlotMarker() - marker.attach(self) - marker.setValue(0.0, 0.0) - marker.setLineStyle(QwtPlotMarker.HLine) - marker.setLabelAlignment(Qt.AlignRight | Qt.AlignTop) - marker.setLabel(QwtText('y = 0')) - # attach a vertical marker at x = pi - marker = QwtPlotMarker() - marker.attach(self) - marker.setValue(np.pi, 0.0) - marker.setLineStyle(QwtPlotMarker.VLine) - marker.setLabelAlignment(Qt.AlignRight | Qt.AlignBottom) - marker.setLabel(QwtText('x = pi')) - # attach a plot image - plotImage = PlotImage('Image') - plotImage.attach(self) - plotImage.setData(square(512, -2*np.pi, 2*np.pi), - (-2*np.pi, 2*np.pi), (-2*np.pi, 2*np.pi)) - - legend.SIG_CLICKED.connect(self.toggleVisibility) - - # replot - self.replot() - - def toggleVisibility(self, plotItem, idx): - """Toggle the visibility of a plot item - """ - plotItem.setVisible(not plotItem.isVisible()) - self.replot() - - -def make(): - demo = ImagePlot() - demo.resize(600, 400) - demo.show() - return demo - - -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) diff --git a/examples/ReallySimpleDemo.py b/examples/ReallySimpleDemo.py deleted file mode 100644 index fead908..0000000 --- a/examples/ReallySimpleDemo.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the PyQwt License -# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) -# (see LICENSE file for more details) - -import sys -import numpy as np - -from qwt.qt.QtGui import QApplication, QPen -from qwt.qt.QtCore import Qt -from qwt import QwtPlot, QwtPlotMarker, QwtLegend, QwtPlotCurve, QwtText - - -class SimplePlot(QwtPlot): - def __init__(self, *args): - QwtPlot.__init__(self, *args) - self.setTitle('ReallySimpleDemo.py') - self.insertLegend(QwtLegend(), QwtPlot.RightLegend) - self.setAxisTitle(QwtPlot.xBottom, 'x -->') - self.setAxisTitle(QwtPlot.yLeft, 'y -->') - self.enableAxis(self.xBottom) - - # insert a few curves - cSin = QwtPlotCurve('y = sin(x)') - cSin.setPen(QPen(Qt.red)) - cSin.attach(self) - cCos = QwtPlotCurve('y = cos(x)') - cCos.setPen(QPen(Qt.blue)) - cCos.attach(self) - - # make a Numeric array for the horizontal data - x = np.arange(0.0, 10.0, 0.1) - - # initialize the data - cSin.setData(x, np.sin(x)) - cCos.setData(x, np.cos(x)) - - # insert a horizontal marker at y = 0 - mY = QwtPlotMarker() - mY.setLabel(QwtText('y = 0')) - mY.setLabelAlignment(Qt.AlignRight | Qt.AlignTop) - mY.setLineStyle(QwtPlotMarker.HLine) - mY.setYValue(0.0) - mY.attach(self) - - # insert a vertical marker at x = 2 pi - mX = QwtPlotMarker() - mX.setLabel(QwtText('x = 2 pi')) - mX.setLabelAlignment(Qt.AlignRight | Qt.AlignTop) - mX.setLineStyle(QwtPlotMarker.VLine) - mX.setXValue(2*np.pi) - mX.attach(self) - - # replot - self.replot() - - -def make(): - demo = SimplePlot() - demo.resize(800, 500) - demo.show() - return demo - - -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - demo.exportTo("demo.png", size=(1600, 900), resolution=200) - sys.exit(app.exec_()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c972b99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +# PythonQwt setup configuration file + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "PythonQwt" +authors = [{ name = "Pierre Raybaut", email = "pierre.raybaut@gmail.com" }] +description = "Qt plotting widgets for Python" +readme = "README.md" +license = { file = "LICENSE" } +classifiers = [ + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Human Machine Interfaces", + "Topic :: Scientific/Engineering :: Visualization", + "Topic :: Software Development :: Widget Sets", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", + "Topic :: Software Development :: User Interfaces", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +requires-python = ">=3.9, <4" +dependencies = ["NumPy>=1.21", "QtPy>=1.9"] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/PlotPyStack/PythonQwt/" +Documentation = "https://PythonQwt.readthedocs.io/en/latest/" + +[project.gui-scripts] +PythonQwt-tests = "qwt.tests:run" + +[project.optional-dependencies] +dev = ["build", "ruff", "pylint", "Coverage", "pre-commit"] +doc = ["PyQt5", "sphinx>6", "python-docs-theme"] +test = ["pytest", "pytest-xvfb"] + +[tool.setuptools.packages.find] +include = ["qwt*"] + +[tool.setuptools.package-data] +"*" = ["*.png", "*.svg", "*.mo", "*.cfg", "*.toml"] + +[tool.setuptools.dynamic] +version = { attr = "qwt.__version__" } + +[tool.pytest.ini_options] +addopts = "qwt" + +[tool.ruff] +exclude = [".git", ".vscode", "build", "dist"] +line-length = 88 # Same as Black. +indent-width = 4 # Same as Black. +target-version = "py39" # Assume Python 3.9. + +[tool.ruff.lint] +# all rules can be found here: https://beta.ruff.rs/docs/rules/ +select = ["E", "F", "W", "I", "NPY201"] +ignore = [ + "E203", # space before : (needed for how black formats slicing) + "E501", # line too long +] + +[tool.ruff.format] +quote-style = "double" # Like Black, use double quotes for strings. +indent-style = "space" # Like Black, indent with spaces, rather than tabs. +skip-magic-trailing-comma = false # Like Black, respect magic trailing commas. +line-ending = "auto" # Like Black, automatically detect the appropriate line ending. + +[tool.ruff.lint.per-file-ignores] +"doc/*" = ["E402"] +"qwt/tests/*" = ["E402"] diff --git a/qwt/__init__.py b/qwt/__init__.py index 6d1f235..8f0c9ed 100644 --- a/qwt/__init__.py +++ b/qwt/__init__.py @@ -5,39 +5,66 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -__version__ = QWT_VERSION_STR = '6.1.2a7' +""" +PythonQwt +========= -import warnings +The ``PythonQwt`` package is a 2D-data plotting library using Qt graphical +user interfaces for the Python programming language. -from qwt.plot import QwtPlot -from qwt.symbol import QwtSymbol as QSbl # see deprecated section -from qwt.scale_engine import QwtLinearScaleEngine, QwtLogScaleEngine -from qwt.text import QwtText -from qwt.plot_canvas import QwtPlotCanvas -from qwt.plot_curve import QwtPlotCurve as QPC # see deprecated section -from qwt.plot_curve import QwtPlotItem -from qwt.scale_map import QwtScaleMap -from qwt.interval import QwtInterval -from qwt.legend import QwtLegend -from qwt.plot_marker import QwtPlotMarker -from qwt.plot_grid import QwtPlotGrid as QPG # see deprecated section -from qwt.color_map import QwtLinearColorMap +It consists of a single Python package named `qwt` which is a pure Python +implementation of Qwt C++ library with some limitations. + +.. image:: /../qwt/tests/data/testlauncher.png -from qwt.toqimage import array_to_qimage as toQImage +External resources: + * Python Package Index: `PyPI`_ + * Project page on GitHub: `GitHubPage`_ + * Bug reports and feature requests: `GitHub`_ -from qwt.scale_div import QwtScaleDiv -from qwt.scale_draw import QwtScaleDraw -from qwt.scale_draw import QwtAbstractScaleDraw -from qwt.series_data import QwtIntervalSeriesData -from qwt.sample import QwtIntervalSample -from qwt.painter import QwtPainter -from qwt.legend_data import QwtLegendData +.. _PyPI: https://pypi.org/project/PythonQwt/ +.. _GitHubPage: https://github.com/PlotPyStack/PythonQwt +.. _GitHub: https://github.com/PlotPyStack/PythonQwt +""" -from qwt.point_data import QwtPointArrayData +import warnings -from qwt.plot_renderer import QwtPlotRenderer +from qwt.color_map import QwtLinearColorMap # noqa: F401 +from qwt.interval import QwtInterval +from qwt.legend import QwtLegend, QwtLegendData, QwtLegendLabel # noqa: F401 +from qwt.painter import QwtPainter # noqa: F401 +from qwt.plot import QwtPlot # noqa: F401 +from qwt.plot_canvas import QwtPlotCanvas # noqa: F401 +from qwt.plot_curve import QwtPlotCurve as QPC # see deprecated section +from qwt.plot_curve import QwtPlotItem # noqa: F401 +from qwt.plot_directpainter import QwtPlotDirectPainter # noqa: F401 +from qwt.plot_grid import QwtPlotGrid as QPG # see deprecated section +from qwt.plot_marker import QwtPlotMarker # noqa: F401 +from qwt.plot_renderer import QwtPlotRenderer # noqa: F401 +from qwt.plot_series import ( # noqa: F401 + QwtPlotSeriesItem, + QwtPointArrayData, + QwtSeriesData, + QwtSeriesStore, +) +from qwt.scale_div import QwtScaleDiv # noqa: F401 +from qwt.scale_draw import ( # noqa: F401 + QwtAbstractScaleDraw, + QwtDateTimeScaleDraw, + QwtScaleDraw, +) +from qwt.scale_engine import ( # noqa: F401 + QwtDateTimeScaleEngine, + QwtLinearScaleEngine, + QwtLogScaleEngine, +) +from qwt.scale_map import QwtScaleMap # noqa: F401 +from qwt.symbol import QwtSymbol as QSbl # see deprecated section +from qwt.text import QwtText # noqa: F401 +from qwt.toqimage import array_to_qimage as toQImage # noqa: F401 -from qwt.plot_directpainter import QwtPlotDirectPainter +__version__ = "0.16.0" +QWT_VERSION_STR = "6.1.5" ## ============================================================================ @@ -47,63 +74,97 @@ # Remove deprecated QwtPlotCanvas.invalidatePaintCache (replaced by replot) ## ============================================================================ class QwtDoubleInterval(QwtInterval): - def __init__(self, minValue=0., maxValue=-1., borderFlags=None): - warnings.warn("`QwtDoubleInterval` has been removed in Qwt6: "\ - "please use `QwtInterval` instead", RuntimeWarning) + def __init__(self, minValue=0.0, maxValue=-1.0, borderFlags=None): + warnings.warn( + "`QwtDoubleInterval` has been removed in Qwt6: " + "please use `QwtInterval` instead", + RuntimeWarning, + ) super(QwtDoubleInterval, self).__init__(minValue, maxValue, borderFlags) + + ## ============================================================================ class QwtLog10ScaleEngine(QwtLogScaleEngine): def __init__(self): - warnings.warn("`QwtLog10ScaleEngine` has been removed in Qwt6: "\ - "please use `QwtLogScaleEngine` instead", - RuntimeWarning) + warnings.warn( + "`QwtLog10ScaleEngine` has been removed in Qwt6: " + "please use `QwtLogScaleEngine` instead", + RuntimeWarning, + ) super(QwtLog10ScaleEngine, self).__init__(10) + + ## ============================================================================ class QwtPlotPrintFilter(object): def __init__(self): - raise NotImplementedError("`QwtPlotPrintFilter` has been removed in Qwt6: "\ - "please rely on `QwtPlotRenderer` instead") + raise NotImplementedError( + "`QwtPlotPrintFilter` has been removed in Qwt6: " + "please rely on `QwtPlotRenderer` instead" + ) + + ## ============================================================================ class QwtPlotCurve(QPC): @property def Yfx(self): - raise NotImplementedError("`Yfx` attribute has been removed "\ - "(curve types are no longer implemented in Qwt6)") + raise NotImplementedError( + "`Yfx` attribute has been removed " + "(curve types are no longer implemented in Qwt6)" + ) + @property def Xfy(self): - raise NotImplementedError("`Yfx` attribute has been removed "\ - "(curve types are no longer implemented in Qwt6)") + raise NotImplementedError( + "`Yfx` attribute has been removed " + "(curve types are no longer implemented in Qwt6)" + ) + + ## ============================================================================ class QwtSymbol(QSbl): def draw(self, painter, *args): - warnings.warn("`draw` has been removed in Qwt6: "\ - "please rely on `drawSymbol` and `drawSymbols` instead", - RuntimeWarning) - from qwt.qt.QtCore import QPointF + warnings.warn( + "`draw` has been removed in Qwt6: " + "please rely on `drawSymbol` and `drawSymbols` instead", + RuntimeWarning, + ) + from qtpy.QtCore import QPointF + if len(args) == 2: self.drawSymbols(painter, [QPointF(*args)]) else: self.drawSymbol(painter, *args) + + ## ============================================================================ class QwtPlotGrid(QPG): def majPen(self): - warnings.warn("`majPen` has been removed in Qwt6: "\ - "please use `majorPen` instead", - RuntimeWarning) + warnings.warn( + "`majPen` has been removed in Qwt6: please use `majorPen` instead", + RuntimeWarning, + ) return self.majorPen() + def minPen(self): - warnings.warn("`minPen` has been removed in Qwt6: "\ - "please use `minorPen` instead", - RuntimeWarning) + warnings.warn( + "`minPen` has been removed in Qwt6: please use `minorPen` instead", + RuntimeWarning, + ) return self.minorPen() + def setMajPen(self, *args): - warnings.warn("`setMajPen` has been removed in Qwt6: "\ - "please use `setMajorPen` instead", - RuntimeWarning) + warnings.warn( + "`setMajPen` has been removed in Qwt6: please use `setMajorPen` instead", + RuntimeWarning, + ) return self.setMajorPen(*args) + def setMinPen(self, *args): - warnings.warn("`setMinPen` has been removed in Qwt6: "\ - "please use `setMinorPen` instead", - RuntimeWarning) + warnings.warn( + "`setMinPen` has been removed in Qwt6: please use `setMinorPen` instead", + RuntimeWarning, + ) return self.setMinorPen(*args) + + ## ============================================================================ diff --git a/qwt/math.py b/qwt/_math.py similarity index 58% rename from qwt/math.py rename to qwt/_math.py index d6eb818..5ee6911 100644 --- a/qwt/math.py +++ b/qwt/_math.py @@ -1,68 +1,75 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.qt.QtCore import qFuzzyCompare - -import numpy as np - - -def qwtFuzzyCompare(value1, value2, intervalSize): - eps = abs(1.e-6*intervalSize) - if value2 - value1 > eps: - return -1 - elif value1 - value2 > eps: - return 1 - else: - return 0 - -def qwtFuzzyGreaterOrEqual(d1, d2): - return (d1 >= d2) or qFuzzyCompare(d1, d2) - -def qwtFuzzyLessOrEqual(d1, d2): - return (d1 <= d2) or qFuzzyCompare(d1, d2) - -def qwtSign(x): - if x > 0.: - return 1 - elif x < 0.: - return -1 - else: - return 0 - -def qwtSqr(x): - return x**2 - -def qwtFastAtan(x): - if x < -1.: - return -.5*np.pi - x/(x**2 + .28) - elif x > 1.: - return .5*np.pi - x/(x**2 + .28) - else: - return x/(1. + x**2*.28) - -def qwtFastAtan2(y, x): - if x > 0: - return qwtFastAtan(y/x) - elif x < 0: - d = qwtFastAtan(y/x) - if y >= 0: - return d + np.pi - else: - return d - np.pi - elif y < 0.: - return -.5*np.pi - elif y > 0.: - return .5*np.pi - else: - return 0. - -def qwtRadians(degrees): - return degrees * np.pi/180. - -def qwtDegrees(radians): - return radians * 180./np.pi - +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# Copyright (c) 2002 Uwe Rathmann, for the original C++ code +# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization +# (see LICENSE file for more details) + +import math + +from qtpy.QtCore import qFuzzyCompare + + +def qwtFuzzyCompare(value1, value2, intervalSize): + eps = abs(1.0e-6 * intervalSize) + if value2 - value1 > eps: + return -1 + elif value1 - value2 > eps: + return 1 + else: + return 0 + + +def qwtFuzzyGreaterOrEqual(d1, d2): + return (d1 >= d2) or qFuzzyCompare(d1, d2) + + +def qwtFuzzyLessOrEqual(d1, d2): + return (d1 <= d2) or qFuzzyCompare(d1, d2) + + +def qwtSign(x): + if x > 0.0: + return 1 + elif x < 0.0: + return -1 + else: + return 0 + + +def qwtSqr(x): + return x**2 + + +def qwtFastAtan(x): + if x < -1.0: + return -0.5 * math.pi - x / (x**2 + 0.28) + elif x > 1.0: + return 0.5 * math.pi - x / (x**2 + 0.28) + else: + return x / (1.0 + x**2 * 0.28) + + +def qwtFastAtan2(y, x): + if x > 0: + return qwtFastAtan(y / x) + elif x < 0: + d = qwtFastAtan(y / x) + if y >= 0: + return d + math.pi + else: + return d - math.pi + elif y < 0.0: + return -0.5 * math.pi + elif y > 0.0: + return 0.5 * math.pi + else: + return 0.0 + + +def qwtRadians(degrees): + return degrees * math.pi / 180.0 + + +def qwtDegrees(radians): + return radians * 180.0 / math.pi diff --git a/qwt/clipper.py b/qwt/clipper.py deleted file mode 100644 index 27b4211..0000000 --- a/qwt/clipper.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.qt.QtGui import QPolygon, QPolygonF -from qwt.qt.QtCore import QRect, QRectF - -import numpy as np - - -class LeftEdge(object): - def __init__(self, x1, x2, y1, y2): - self.__x1 = x1 - - def isInside(self, p): - return p.x() >= self.__x1 - - def intersection(self, p1, p2): - dy = (p1.y()-p2.y())/(p1.x()-p2.x()) - return Point(self.__x1, (p2.y() + (self.__x1 - p2.x())*dy)) - -class RightEdge(object): - def __init__(self, x1, x2, y1, y2): - self.__x2 = x2 - - def isInside(self, p): - return p.x() <= self.__x2 - - def intersection(self, p1, p2): - dy = (p1.y()-p2.y())/(p1.x()-p2.x()) - return Point(self.__x2, (p2.y() + (self.__x2 - p2.x())*dy)) - -class TopEdge(object): - def __init__(self, x1, x2, y1, y2): - self.__y1 = y1 - - def isInside(self, p): - return p.y() >= self.__y1 - - def intersection(self, p1, p2): - dx = (p1.x()-p2.x())/(p1.y()-p2.y()) - return Point((p2.x() + (self.__y1 - p2.y())*dx), self.__y1) - -class BottomEdge(object): - def __init__(self, x1, x2, y1, y2): - self.__y2 = y2 - - def isInside(self, p): - return p.y() <= self.__y2 - - def intersection(self, p1, p2): - dx = (p1.x()-p2.x())/(p1.y()-p2.y()) - return Point((p2.x() + (self.__y2 - p2.y())*dx), self.__y2) - -class PointBuffer(object): - def __init__(self, capacity=0): - self.m_capacity = capacity - self.m_size = 0 - self.m_buffer = None - if capacity > 0: - self.reserve(capacity) - - def setPoints(self, numPoints, points): - self.reserve(numPoints) - self.m_size = numPoints - - def reset(self): - self.m_size = 0 - - def size(self): - return self.m_size - - def data(self): - return self.m_buffer - - def operator(self, i): - return self.m_buffer[i] - - -class QwtPolygonClipper(object): - def __init__(self, clipRect): - self.__clipRect = clipRect - - def clipPolygon(self, polygon, closePolygon): - if self.__clipRect.contains(polygon.boundingRect()): - return polygon - - - -class QwtClipper(object): - def __init__(self): - pass - - def clipPolygon(self, clipRect, polygon, closePolygon): - raise NotImplementedError("Nearly impossible to implement in pure Python") - #XXX: the only viable option would be to use Qt's intersected method - # but unfortunately it's closing systematically the output polygon - # (how to test it: polygon.intersected(QPolygonF(clipRect))) - - if isinstance(clipRect, QRectF): - minX = np.ceil(clipRect.left()) - maxX = np.floor(clipRect.right()) - minY = np.ceil(clipRect.top()) - maxY = np.floor(clipRect.bottom()) - clipRect = QRect(minX, minY, maxX-minX, maxY-minY) - clipper = QwtPolygonClipper(clipRect) - return clipper.clipPolygon(polygon, closePolygon) - - def clipPolygonF(self, clipRect, polygon, closePolygon): - raise NotImplementedError("Nearly impossible to implement in pure Python") - #XXX: the only viable option would be to use Qt's intersected method - # but unfortunately it's closing systematically the output polygon - # (how to test it: polygon.intersected(QPolygonF(clipRect))) - - if isinstance(clipRect, QRect): - clipRect = QRectF(clipRect) - clipper = QwtPolygonClipper(clipRect) - return clipper.clipPolygon(polygon, closePolygon) diff --git a/qwt/color_map.py b/qwt/color_map.py index 901d17c..9a54cf0 100644 --- a/qwt/color_map.py +++ b/qwt/color_map.py @@ -5,12 +5,35 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.qt.QtGui import QColor, qRed, qGreen, qBlue, qRgb, qRgba, qAlpha -from qwt.qt.QtCore import Qt, qIsNaN +""" +Color maps +---------- + +QwtColorMap +~~~~~~~~~~~ + +.. autoclass:: QwtColorMap + :members: + +QwtLinearColorMap +~~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtLinearColorMap + :members: + +QwtAlphaColorMap +~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtAlphaColorMap + :members: +""" + +from qtpy.QtCore import QObject, Qt, qIsNaN +from qtpy.QtGui import QColor, qAlpha, qBlue, qGreen, qRed, qRgb, qRgba class ColorStop(object): - def __init__(self, pos=0., color=None): + def __init__(self, pos=0.0, color=None): self.pos = pos if color is None: self.rgb = 0 @@ -20,18 +43,18 @@ def __init__(self, pos=0., color=None): self.g = qGreen(self.rgb) self.b = qBlue(self.rgb) self.a = qAlpha(self.rgb) - - # when mapping a value to rgb we will have to calcualate: + + # when mapping a value to rgb we will have to calcualate: # - const int v = int( ( s1.v0 + ratio * s1.vStep ) + 0.5 ); # Thus adding 0.5 ( for rounding ) can be done in advance self.r0 = self.r + 0.5 self.g0 = self.g + 0.5 self.b0 = self.b + 0.5 self.a0 = self.a + 0.5 - - self.rStep = self.gStep = self.bStep = self.aStep = 0. - self.posStep = 0. - + + self.rStep = self.gStep = self.bStep = self.aStep = 0.0 + self.posStep = 0.0 + def updateSteps(self, nextStop): self.rStep = nextStop.r - self.r self.gStep = nextStop.g - self.g @@ -44,66 +67,64 @@ class ColorStops(object): def __init__(self): self.__doAlpha = False self.__stops = [] - self.stops = [] - + def insert(self, pos, color): - if pos < 0. or pos > 1.: + if pos < 0.0 or pos > 1.0: return if len(self.__stops) == 0: index = 0 self.__stops = [None] else: index = self.findUpper(pos) - if index == len(self.__stops) or\ - abs(self.__stops[index].pos-pos) >= .001: + if ( + index == len(self.__stops) + or abs(self.__stops[index].pos - pos) >= 0.001 + ): self.__stops.append(None) - for i in range(len(self.__stops)-1, index, -1): - self.__stops[i] = self.__stops[i-1] + for i in range(len(self.__stops) - 1, index, -1): + self.__stops[i] = self.__stops[i - 1] self.__stops[index] = ColorStop(pos, color) - if color.alpha() != 255: - self.__doAlpha = True + self.__doAlpha = color.alpha() != 255 if index > 0: - self.__stops[index-1].updateSteps(self.__stops[index]) - if index < len(self.__stops)-1: - self.__stops[index].updateSteps(self.__stops[index+1]) - + self.__stops[index - 1].updateSteps(self.__stops[index]) + if index < len(self.__stops) - 1: + self.__stops[index].updateSteps(self.__stops[index + 1]) + def stops(self): - return [stop.pos for stop in self.__stops] - + return self.__stops + def findUpper(self, pos): index = 0 n = len(self.__stops) - - stops = self.__stops - + while n > 0: half = n >> 1 middle = index + half - if stops[middle].pos <= pos: + if self.__stops[middle].pos <= pos: index = middle + 1 n -= half + 1 else: n = half return index - + def rgb(self, mode, pos): - if pos <= 0.: + if pos <= 0.0: return self.__stops[0].rgb if pos >= 1.0: return self.__stops[-1].rgb - + index = self.findUpper(pos) if mode == QwtLinearColorMap.FixedColors: - return self.__stops[index-1].rgb + return self.__stops[index - 1].rgb else: - s1 = self.__stops[index-1] - ratio = (pos-s1.pos)/s1.posStep - r = int(s1.r0 + ratio*s1.rStep) - g = int(s1.g0 + ratio*s1.gStep) - b = int(s1.b0 + ratio*s1.bStep) + s1 = self.__stops[index - 1] + ratio = (pos - s1.pos) / s1.posStep + r = int(s1.r0 + ratio * s1.rStep) + g = int(s1.g0 + ratio * s1.gStep) + b = int(s1.b0 + ratio * s1.bStep) if self.__doAlpha: if s1.aStep: - a = int(s1.a0 + ratio*s1.aStep) + a = int(s1.a0 + ratio * s1.aStep) return qRgba(r, g, b, a) else: return qRgba(r, g, b, s1.a) @@ -112,137 +133,254 @@ def rgb(self, mode, pos): class QwtColorMap(object): - + """ + QwtColorMap is used to map values into colors. + + For displaying 3D data on a 2D plane the 3rd dimension is often + displayed using colors, like f.e in a spectrogram. + + Each color map is optimized to return colors for only one of the + following image formats: + + * `QImage.Format_Indexed8` + * `QImage.Format_ARGB32` + + .. py:class:: QwtColorMap(format_) + + :param int format_: Preferred format of the color map (:py:data:`QwtColorMap.RGB` or :py:data:`QwtColorMap.Indexed`) + + .. seealso :: + + :py:data:`qwt.QwtScaleWidget` + """ + # enum Format RGB, Indexed = list(range(2)) - + def __init__(self, format_=None): if format_ is None: format_ = self.RGB self.__format = format_ - + def color(self, interval, value): + """ + Map a value into a color + + :param qwt.interval.QwtInterval interval: valid interval for value + :param float value: value + :return: the color corresponding to value + + .. warning :: + + This method is slow for Indexed color maps. If it is necessary to + map many values, its better to get the color table once and find + the color using `colorIndex()`. + """ if self.__format == self.RGB: return QColor.fromRgba(self.rgb(interval, value)) else: index = self.colorIndex(interval, value) return self.colorTable(interval)[index] - + def format(self): return self.__format - + def colorTable(self, interval): + """ + Build and return a color map of 256 colors + + :param qwt.interval.QwtInterval interval: range for the values + :return: a color table, that can be used for a `QImage` + + The color table is needed for rendering indexed images in combination + with using `colorIndex()`. + """ table = [0] * 256 if interval.isValid(): - step = interval.width()/(len(table)-1) + step = interval.width() / (len(table) - 1) for i in range(len(table)): - table[i] = self.rgb(interval, interval.minValue()+step*i) + table[i] = self.rgb(interval, interval.minValue() + step * i) return table + def rgb(self, interval, value): + # To be reimplemented + return QColor().rgb() + + def colorIndex(self, interval, value): + # To be reimplemented + return 0 + -class QwtLinearColorMap_PrivateData(object): +class QwtLinearColorMap_PrivateData(QObject): def __init__(self): - self.colorStops = None + QObject.__init__(self) + + self.colorStops = ColorStops() self.mode = None class QwtLinearColorMap(QwtColorMap): - + """ + Build a linear color map with two stops. + + .. py:class:: QwtLinearColorMap(format_) + + Build a color map with two stops at 0.0 and 1.0. + The color at 0.0 is `Qt.blue`, at 1.0 it is `Qt.yellow`. + + :param int format_: Preferred format of the color map (:py:data:`QwtColorMap.RGB` or :py:data:`QwtColorMap.Indexed`) + + .. py:class:: QwtLinearColorMap(color1, color2, [format_=QwtColorMap.RGB]): + :noindex: + + Build a color map with two stops at 0.0 and 1.0. + + :param QColor color1: color at 0. + :param QColor color2: color at 1. + :param int format_: Preferred format of the color map (:py:data:`QwtColorMap.RGB` or :py:data:`QwtColorMap.Indexed`) + """ + # enum Mode FixedColors, ScaledColors = list(range(2)) - + def __init__(self, *args): color1, color2 = QColor(Qt.blue), QColor(Qt.yellow) format_ = QwtColorMap.RGB if len(args) == 1: - format_, = args + (format_,) = args elif len(args) == 2: color1, color2 = args elif len(args) == 3: color1, color2, format_ = args elif len(args) != 0: - raise TypeError("%s() takes 0, 1, 2 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s() takes 0, 1, 2 or 3 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) super(QwtLinearColorMap, self).__init__(format_) self.__data = QwtLinearColorMap_PrivateData() self.__data.mode = self.ScaledColors self.setColorInterval(color1, color2) - + def setMode(self, mode): + """ + Set the mode of the color map + + :param int mode: :py:data:`QwtLinearColorMap.FixedColors` or :py:data:`QwtLinearColorMap.ScaledColors` + + `FixedColors` means the color is calculated from the next lower color + stop. `ScaledColors` means the color is calculated by interpolating + the colors of the adjacent stops. + """ self.__data.mode = mode - + def mode(self): + """ + :return: the mode of the color map + + .. seealso :: + + :py:meth:`QwtLinearColorMap.setMode` + """ return self.__data.mode - + def setColorInterval(self, color1, color2): self.__data.colorStops = ColorStops() - self.__data.colorStops.insert(0., QColor(color1)) - self.__data.colorStops.insert(1., QColor(color2)) - + self.__data.colorStops.insert(0.0, QColor(color1)) + self.__data.colorStops.insert(1.0, QColor(color2)) + def addColorStop(self, value, color): - if value >= 0. and value <= 1.: + if value >= 0.0 and value <= 1.0: self.__data.colorStops.insert(value, QColor(color)) - + def colorStops(self): return self.__data.colorStops.stops() - + def color1(self): - return QColor(self.__data.colorStops.rgb(self.__data.mode, 0.)) - + return QColor(self.__data.colorStops.rgb(self.__data.mode, 0.0)) + def color2(self): - return QColor(self.__data.colorStops.rgb(self.__data.mode, 1.)) - + return QColor(self.__data.colorStops.rgb(self.__data.mode, 1.0)) + def rgb(self, interval, value): if qIsNaN(value): return 0 width = interval.width() - if width <= 0.: + if width <= 0.0: return 0 - ratio = (value-interval.minValue())/width + ratio = (value - interval.minValue()) / width return self.__data.colorStops.rgb(self.__data.mode, ratio) - + def colorIndex(self, interval, value): width = interval.width() - if qIsNaN(value) or width <= 0. or value <= interval.minValue(): + if qIsNaN(value) or width <= 0.0 or value <= interval.minValue(): return 0 if value >= interval.maxValue(): return 255 - ratio = (value-interval.minValue())/width + ratio = (value - interval.minValue()) / width if self.__data.mode == self.FixedColors: - return int(ratio*255) + return int(ratio * 255) else: - return int(ratio*255+.5) - + return int(ratio * 255 + 0.5) -class QwtAlphaColorMap_PrivateData(object): + +class QwtAlphaColorMap_PrivateData(QObject): def __init__(self): - self.color = None - self.rgb = None - self.rgbMax = None + QObject.__init__(self) + + self.color = QColor() + self.rgb = QColor().rgb() + self.rgbMax = QColor().rgb() + class QwtAlphaColorMap(QwtColorMap): + """ + QwtAlphaColorMap varies the alpha value of a color + + .. py:class:: QwtAlphaColorMap(color) + + Build a color map varying the alpha value of a color. + + :param QColor color: color of the map + """ + def __init__(self, color): super(QwtAlphaColorMap, self).__init__(QwtColorMap.RGB) self.__data = QwtAlphaColorMap_PrivateData() self.setColor(color) - + def setColor(self, color): + """ + Set the color of the map + + :param QColor color: color of the map + """ self.__data.color = color self.__data.rgb = color.rgb() & qRgba(255, 255, 255, 0) - self.__data.rgbMax = self.__data.rgb | ( 255 << 24 ) - + self.__data.rgbMax = self.__data.rgb | (255 << 24) + def color(self): - return self.__data.color() - + """ + :return: the color of the map + + .. seealso :: + + :py:meth:`QwtAlphaColorMap.setColor` + """ + return self.__data.color + def rgb(self, interval, value): if qIsNaN(value): return 0 width = interval.width() - if width <= 0.: + if width <= 0.0: return 0 if value <= interval.minValue(): return self.__data.rgb if value >= interval.maxValue(): return self.__data.rgbMax - ratio = (value-interval.minValue())/width - return self.__data.rgb | (int(round(255*ratio)) << 24) + ratio = (value - interval.minValue()) / width + return self.__data.rgb | (int(round(255 * ratio)) << 24) + + def colorIndex(self, interval, value): + return 0 diff --git a/qwt/column_symbol.py b/qwt/column_symbol.py index 586a24d..c4e46b0 100644 --- a/qwt/column_symbol.py +++ b/qwt/column_symbol.py @@ -5,50 +5,49 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.interval import QwtInterval -from qwt.painter import QwtPainter +from qtpy.QtCore import QLineF, QObject, QRectF, Qt +from qtpy.QtGui import QPalette, QPolygonF -from qwt.qt.QtGui import QPolygonF, QPalette -from qwt.qt.QtCore import QRectF, Qt +from qwt.interval import QwtInterval def qwtDrawBox(p, rect, pal, lw): - if lw > 0.: - if rect.width() == 0.: + if lw > 0.0: + if rect.width() == 0.0: p.setPen(pal.dark().color()) - p.drawLine(rect.topLeft(), rect.bottomLeft()) + p.drawLine(QLineF(rect.topLeft(), rect.bottomLeft())) return - if rect.height() == 0.: + if rect.height() == 0.0: p.setPen(pal.dark().color()) - p.drawLine(rect.topLeft(), rect.topRight()) + p.drawLine(QLineF(rect.topLeft(), rect.topRight())) return - lw = min([lw, rect.height()/2.-1.]) - lw = min([lw, rect.width()/2.-1.]) + lw = min([lw, rect.height() / 2.0 - 1.0]) + lw = min([lw, rect.width() / 2.0 - 1.0]) outerRect = rect.adjusted(0, 0, 1, 1) polygon = QPolygonF(outerRect) - if outerRect.width() > 2*lw and outerRect.height() > 2*lw: + if outerRect.width() > 2 * lw and outerRect.height() > 2 * lw: innerRect = outerRect.adjusted(lw, lw, -lw, -lw) polygon = polygon.subtracted(innerRect) p.setPen(Qt.NoPen) p.setBrush(pal.dark()) p.drawPolygon(polygon) - windowRect = rect.adjusted(lw, lw, -lw+1, -lw+1) + windowRect = rect.adjusted(lw, lw, -lw + 1, -lw + 1) if windowRect.isValid(): p.fillRect(windowRect, pal.window()) def qwtDrawPanel(painter, rect, pal, lw): - if lw > 0.: - if rect.width() == 0.: + if lw > 0.0: + if rect.width() == 0.0: painter.setPen(pal.window().color()) - painter.drawLine(rect.topLeft(), rect.bottomLeft()) + painter.drawLine(QLineF(rect.topLeft(), rect.bottomLeft())) return - if rect.height() == 0.: + if rect.height() == 0.0: painter.setPen(pal.window().color()) - painter.drawLine(rect.topLeft(), rect.topRight()) + painter.drawLine(QLineF(rect.topLeft(), rect.topRight())) return - lw = min([lw, rect.height()/2.-1.]) - lw = min([lw, rect.width()/2.-1.]) + lw = min([lw, rect.height() / 2.0 - 1.0]) + lw = min([lw, rect.width() / 2.0 - 1.0]) outerRect = rect.adjusted(0, 0, 1, 1) innerRect = outerRect.adjusted(lw, lw, -lw, -lw) lines = [QPolygonF(), QPolygonF()] @@ -69,89 +68,88 @@ def qwtDrawPanel(painter, rect, pal, lw): painter.drawPolygon(lines[0]) painter.setBrush(pal.dark()) painter.drawPolygon(lines[1]) - painter.fillRect(rect.adjusted(lw, lw, -lw+1, -lw+1), pal.window()) + painter.fillRect(rect.adjusted(lw, lw, -lw + 1, -lw + 1), pal.window()) -class QwtColumnSymbol_PrivateData(object): +class QwtColumnSymbol_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.style = QwtColumnSymbol.Box self.frameStyle = QwtColumnSymbol.Raised self.lineWidth = 2 self.palette = QPalette(Qt.gray) + class QwtColumnSymbol(object): - # enum Style NoStyle = -1 Box = 0 UserStyle = 1000 - + # enum FrameStyle NoFrame, Plain, Raised = list(range(3)) - + def __init__(self, style): self.__data = QwtColumnSymbol_PrivateData() self.__data.style = style - + def setStyle(self, style): self.__data.style = style - + def style(self): return self.__data.style - + def setPalette(self, palette): self.__data.palette = palette - + def palette(self): return self.__data.palette def setFrameStyle(self, frameStyle): self.__data.frameStyle = frameStyle - + def frameStyle(self): return self.__data.frameStyle def setLineWidth(self, width): self.__data.lineWidth = width - + def lineWidth(self): return self.__data.lineWidth - + def draw(self, painter, rect): painter.save() if self.__data.style == QwtColumnSymbol.Box: self.drawBox(painter, rect) painter.restore() - + def drawBox(self, painter, rect): r = rect.toRect() - if QwtPainter().roundingAlignment(painter): - r.setLeft(round(r.left())) - r.setRight(round(r.right())) - r.setTop(round(r.top())) - r.setBottom(round(r.bottom())) if self.__data.frameStyle == QwtColumnSymbol.Raised: qwtDrawPanel(painter, r, self.__data.palette, self.__data.lineWidth) elif self.__data.frameStyle == QwtColumnSymbol.Plain: qwtDrawBox(painter, r, self.__data.palette, self.__data.lineWidth) else: - painter.fillRect(r, self.__data.palette.window()) + painter.fillRect(r.adjusted(0, 0, 1, 1), self.__data.palette.window()) class QwtColumnRect(object): - # enum Direction LeftToRight, RightToLeft, BottomToTop, TopToBottom = list(range(4)) - + def __init__(self): self.hInterval = QwtInterval() self.vInterval = QwtInterval() self.direction = 0 - + def toRect(self): - r = QRectF(self.hInterval.minValue(), self.vInterval.minValue(), - self.hInterval.maxValue()-self.hInterval.minValue(), - self.vInterval.maxValue()-self.vInterval.minValue()) + r = QRectF( + self.hInterval.minValue(), + self.vInterval.minValue(), + self.hInterval.maxValue() - self.hInterval.minValue(), + self.vInterval.maxValue() - self.vInterval.minValue(), + ) r = r.normalized() if self.hInterval.borderFlags() & QwtInterval.ExcludeMinimum: r.adjust(1, 0, 0, 0) @@ -162,9 +160,8 @@ def toRect(self): if self.vInterval.borderFlags() & QwtInterval.ExcludeMaximum: r.adjust(0, 0, 0, -1) return r - + def orientation(self): if self.direction in (self.LeftToRight, self.RightToLeft): return Qt.Horizontal return Qt.Vertical - diff --git a/qwt/curve_fitter.py b/qwt/curve_fitter.py deleted file mode 100644 index 9b72d6e..0000000 --- a/qwt/curve_fitter.py +++ /dev/null @@ -1,208 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.spline import QwtSpline - -from qwt.qt.QtGui import QPolygonF -from qwt.qt.QtCore import QPointF - -import numpy as np - - -class QwtCurveFitter(object): - def __init__(self): - pass - - -class QwtSplineCurveFitter_PrivateData(object): - def __init__(self): - self.fitMode = QwtSplineCurveFitter.Auto - self.splineSize = 250 - self.spline = QwtSpline() - - -class QwtSplineCurveFitter(QwtCurveFitter): - # enum FitMode - Auto, Spline, ParametricSpline = list(range(3)) - - def __init__(self): - super(QwtSplineCurveFitter, self).__init__() - self.__data = QwtSplineCurveFitter_PrivateData() - - def setFitMode(self, mode): - self.__data.fitMode = mode - - def fitMode(self): - return self.__data.fitMode - - def setSpline(self, spline): - self.__data.spline = spline - self.__data.spline.reset() - - def spline(self): - return self.__data.spline - - def setSplineSize(self, splineSize): - self.__data.splineSize = max([splineSize, 10]) - - def splineSize(self): - return self.__data.splineSize - - def fitCurve(self, points): - size = points.size() - if size <= 2: - return points - fitMode = self.__data.fitMode - if fitMode == self.Auto: - fitMode = self.Spline - p = points#.data() - for i in range(1, size): - if p[i].x() <= p[i-1].x(): - fitMode = self.ParametricSpline - break - if fitMode == self.ParametricSpline: - return self.fitParametric(points) - else: - return self.fitSpline(points) - - def fitSpline(self, points): - self.__data.spline.setPoints(points) - if not self.__data.spline.isValid(): - return points - fittedPoints = QPolygonF(self.__data.splineSize) - x1 = points[0].x() - x2 = points[int(points.size()-1)].x() - dx = x2 - x1 - delta = dx/(self.__data.splineSize()-1) - for i in range(self.__data.splineSize): - v = x1 + i*delta - sv = self.__data.spline.value(v) - fittedPoints[i] = QPointF(v, sv) - self.__data.spline.reset() - return fittedPoints - - def fitParametric(self, points): - size = points.size() - fittedPoints = QPolygonF(self.__data.splineSize) - splinePointsX = QPolygonF(size) - splinePointsY = QPolygonF(size) -# p = points.data() - spX = splinePointsX#.data() - spY = splinePointsY#.data() - param = 0. - for i in range(size): - x = points[i].x() - y = points[i].y() - if i > 0: - delta = np.sqrt((x-spX[i-1].y())**2+(y-spY[i-1].y())**2) - param += max([delta, 1.]) - spX[i] = QPointF(param, x) - spY[i] = QPointF(param, y) - self.__data.spline.setPoints(splinePointsX) - if not self.__data.spline.isValid(): - return points - deltaX = splinePointsX[size-1].x()/(self.__data.splineSize-1) - for i in range(self.__data.splineSize): - dtmp = i*deltaX - fittedPoints[i] = QPointF(self.__data.spline.value(dtmp), - fittedPoints[i].y()) - self.__data.spline.setPoints(splinePointsY) - if not self.__data.spline.isValid(): - return points - deltaY = splinePointsY[size-1].x()/(self.__data.splineSize-1) - for i in range(self.__data.splineSize): - dtmp = i*deltaY - fittedPoints[i] = QPointF(fittedPoints[i].x(), - self.__data.spline.value(dtmp)) - return fittedPoints - - -class QwtWeedingCurveFitter_PrivateData(object): - def __init__(self): - self.tolerance = 1. - self.chunkSize = 0 - -class QwtWeedingCurveFitter_Line(object): - def __init__(self, i1=0, i2=0): - self.from_ = i1 - self.to = i2 - -class QwtWeedingCurveFitter(QwtCurveFitter): - def __init__(self, tolerance=1.): - super(QwtWeedingCurveFitter, self).__init__() - self.__data = QwtWeedingCurveFitter_PrivateData() - self.setTolerance(tolerance) - - def setTolerance(self, tolerance): - self.__data.tolerance = max([tolerance, 0.]) - - def tolerance(self): - return self.__data.tolerance - - def setChunkSize(self, numPoints): - if numPoints > 0: - numPoints = max([numPoints, 3]) - self.__data.chunkSize = numPoints - - def chunkSize(self): - return self.__data.chunkSize - - def fitCurve(self, points): - fittedPoints = QPolygonF() - if self.__data.chunkSize == 0: - fittedPoints = self.simplify(points) - else: - for i in range(0, points.size(), self.__data.chunkSize): - p = points.mid(i, self.__data.chunkSize) - fittedPoints += self.simplify(p) - return fittedPoints - - def simplify(self, points): - Line = QwtWeedingCurveFitter_Line - toleranceSqr = self.__data.tolerance*self.__data.tolerance - stack = [] - p = points.data() - nPoints = points.size() - usePoint = [False]*nPoints - stack.insert(0, Line(0, nPoints-1)) - while stack: - r = stack.pop(0) - vecX = p[r.to].x()-p[r.from_].x() - vecY = p[r.to].y()-p[r.from_].y() - vecLength = np.sqrt(vecX**2+vecY**2) - unitVecX = vecX/vecLength if vecLength != 0. else 0. - unitVecY = vecY/vecLength if vecLength != 0. else 0. - maxDistSqr = 0. - nVertexIndexMaxDistance = r.from_ + 1 - for i in range(r.from_+1, r.to): - fromVecX = p[i].x()-p[r.from_].x() - fromVecY = p[i].y()-p[r.from_].y() - if fromVecX * unitVecX + fromVecY * unitVecY < 0.0: - distToSegmentSqr = fromVecX * fromVecX + fromVecY * fromVecY - else: - toVecX = p[i].x() - p[r.to].x() - toVecY = p[i].y() - p[r.to].y() - toVecLength = toVecX * toVecX + toVecY * toVecY - s = toVecX * ( -unitVecX ) + toVecY * ( -unitVecY ) - if s < 0.: - distToSegmentSqr = toVecLength - else: - distToSegmentSqr = abs( toVecLength - s * s ) - if maxDistSqr < distToSegmentSqr: - maxDistSqr = distToSegmentSqr - nVertexIndexMaxDistance = i - if maxDistSqr <= toleranceSqr: - usePoint[r.from_] = True - usePoint[r.to] = True - else: - stack.insert(0, Line( r.from_, nVertexIndexMaxDistance )) - stack.insert(0, Line( nVertexIndexMaxDistance, r.to )) - stripped = QPolygonF() - for i in range(0, nPoints): - if usePoint[i]: - stripped += p[i] - return stripped diff --git a/qwt/dyngrid_layout.py b/qwt/dyngrid_layout.py index 5b0683b..45850da 100644 --- a/qwt/dyngrid_layout.py +++ b/qwt/dyngrid_layout.py @@ -5,25 +5,66 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.qt.QtGui import QLayout -from qwt.qt.QtCore import Qt, QRect, QSize +""" +qwt.dyngrid_layout +------------------ +The `dyngrid_layout` module provides the `QwtDynGridLayout` class. -class QwtDynGridLayout_PrivateData(object): +.. autoclass:: QwtDynGridLayout + :members: +""" + +from qtpy.QtCore import QObject, QRect, QSize, Qt +from qtpy.QtWidgets import QLayout + + +class QwtDynGridLayout_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.isDirty = True self.maxColumns = 0 self.numRows = 0 self.numColumns = 0 - self.expanding = Qt.Orientations() + self.expanding = Qt.Horizontal self.itemSizeHints = [] self.itemList = [] - + def updateLayoutCache(self): self.itemSizeHints = [it.sizeHint() for it in self.itemList] self.isDirty = False - + + class QwtDynGridLayout(QLayout): + """ + The `QwtDynGridLayout` class lays out widgets in a grid, + adjusting the number of columns and rows to the current size. + + `QwtDynGridLayout` takes the space it gets, divides it up into rows and + columns, and puts each of the widgets it manages into the correct cell(s). + It lays out as many number of columns as possible (limited by + :py:meth:`maxColumns()`). + + .. py:class:: QwtDynGridLayout(parent, margin, [spacing=-1]) + + :param QWidget parent: parent widget + :param int margin: margin + :param int spacing: spacing + + .. py:class:: QwtDynGridLayout(spacing) + :noindex: + + :param int spacing: spacing + + .. py:class:: QwtDynGridLayout() + :noindex: + + Initialize the layout with default values. + + :param int spacing: spacing + """ + def __init__(self, *args): self.__data = None parent = None @@ -35,70 +76,103 @@ def __init__(self, *args): spacing = args[-1] elif len(args) == 1: if isinstance(args[0], int): - spacing, = args + (spacing,) = args else: - parent, = args + (parent,) = args elif len(args) != 0: - raise TypeError("%s() takes 0, 1, 2 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s() takes 0, 1, 2 or 3 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) QLayout.__init__(self, parent) self.__data = QwtDynGridLayout_PrivateData() self.setSpacing(spacing) self.setContentsMargins(margin, margin, margin, margin) - + def invalidate(self): + """Invalidate all internal caches""" self.__data.isDirty = True QLayout.invalidate(self) - + def setMaxColumns(self, maxColumns): + """Limit the number of columns""" self.__data.maxColumns = maxColumns - + def maxColumns(self): + """Return the upper limit for the number of columns""" return self.__data.maxColumns - + def addItem(self, item): + """Add an item to the next free position""" self.__data.itemList.append(item) self.invalidate() - + def isEmpty(self): + """Return true if this layout is empty""" return self.count() == 0 - + def itemCount(self): + """Return number of layout items""" return self.count() - + def itemAt(self, index): + """Find the item at a specific index""" if index < 0 or index >= len(self.__data.itemList): return return self.__data.itemList[index] - + def takeAt(self, index): + """Find the item at a specific index and remove it from the layout""" if index < 0 or index >= len(self.__data.itemList): return self.__data.isDirty = True return self.__data.itemList.pop(index) - + def count(self): + """Return Number of items in the layout""" return len(self.__data.itemList) - + def setExpandingDirections(self, expanding): + """ + Set whether this layout can make use of more space than sizeHint(). + A value of Qt.Vertical or Qt.Horizontal means that it wants to grow in + only one dimension, while Qt.Vertical | Qt.Horizontal means that it + wants to grow in both dimensions. The default value is 0. + """ self.__data.expanding = expanding - + def expandingDirections(self): + """ + Returns whether this layout can make use of more space than sizeHint(). + A value of Qt.Vertical or Qt.Horizontal means that it wants to grow in + only one dimension, while Qt.Vertical | Qt.Horizontal means that it + wants to grow in both dimensions. + """ return self.__data.expanding - + def setGeometry(self, rect): + """ + Reorganizes columns and rows and resizes managed items within a + rectangle. + """ QLayout.setGeometry(self, rect) if self.isEmpty(): return self.__data.numColumns = self.columnsForWidth(rect.width()) - self.__data.numRows = self.itemCount()/self.__data.numColumns + self.__data.numRows = self.itemCount() / self.__data.numColumns if self.itemCount() % self.__data.numColumns: self.__data.numRows += 1 itemGeometries = self.layoutItems(rect, self.__data.numColumns) for it, geo in zip(self.__data.itemList, itemGeometries): it.setGeometry(geo) - + def columnsForWidth(self, width): + """ + Calculate the number of columns for a given width. + + The calculation tries to use as many columns as possible + ( limited by maxColumns() ) + """ if self.isEmpty(): return 0 maxColumns = self.itemCount() @@ -106,13 +180,14 @@ def columnsForWidth(self, width): maxColumns = min([self.__data.maxColumns, maxColumns]) if self.maxRowWidth(maxColumns) <= width: return maxColumns - for numColumns in range(2, maxColumns+1): + for numColumns in range(2, maxColumns + 1): rowWidth = self.maxRowWidth(numColumns) if rowWidth > width: - return numColumns-1 + return numColumns - 1 return 1 - + def maxRowWidth(self, numColumns): + """Calculate the width of a layout for a given number of columns.""" colWidth = [0] * numColumns if self.__data.isDirty: self.__data.updateLayoutCache() @@ -121,29 +196,34 @@ def maxRowWidth(self, numColumns): colWidth[col] = max([colWidth[col], hint.width()]) margins = self.contentsMargins() margin_w = margins.left() + margins.right() - return margin_w+(numColumns-1)*self.spacing()+sum(colWidth) - + return margin_w + (numColumns - 1) * self.spacing() + sum(colWidth) + def maxItemWidth(self): + """Return the maximum width of all layout items""" if self.isEmpty(): return 0 if self.__data.isDirty: self.__data.updateLayoutCache() return max([hint.width() for hint in self.__data.itemSizeHints]) - + def layoutItems(self, rect, numColumns): + """ + Calculate the geometries of the layout items for a layout + with numColumns columns and a given rectangle. + """ itemGeometries = [] if numColumns == 0 or self.isEmpty(): return itemGeometries - numRows = int(self.itemCount()/numColumns) + numRows = int(self.itemCount() / numColumns) if numColumns % self.itemCount(): numRows += 1 if numRows == 0: return itemGeometries - rowHeight = [0]*numRows - colWidth = [0]*numColumns + rowHeight = [0] * numRows + colWidth = [0] * numColumns self.layoutGrid(numColumns, rowHeight, colWidth) - expandH = self.expandingDirections() & Qt.Horizontal - expandV = self.expandingDirections() & Qt.Vertical + expandH = self.expandingDirections() == Qt.Horizontal + expandV = self.expandingDirections() == Qt.Vertical if expandH or expandV: self.stretchGrid(rect, numColumns, rowHeight, colWidth) maxColumns = self.__data.maxColumns @@ -158,26 +238,29 @@ def layoutItems(self, rect, numColumns): margins = self.contentsMargins() rowY[0] = yOffset + margins.bottom() for r in range(1, numRows): - rowY[r] = rowY[r-1] + rowHeight[r-1] + xySpace + rowY[r] = rowY[r - 1] + rowHeight[r - 1] + xySpace colX[0] = xOffset + margins.left() for c in range(1, numColumns): - colX[c] = colX[c-1] + colWidth[c-1] + xySpace + colX[c] = colX[c - 1] + colWidth[c - 1] + xySpace itemCount = len(self.__data.itemList) for i in range(itemCount): - row = int(i/numColumns) + row = int(i / numColumns) col = i % numColumns - itemGeometry = QRect(colX[col], rowY[row], - colWidth[col], rowHeight[row]) + itemGeometry = QRect(colX[col], rowY[row], colWidth[col], rowHeight[row]) itemGeometries.append(itemGeometry) return itemGeometries - + def layoutGrid(self, numColumns, rowHeight, colWidth): + """ + Calculate the dimensions for the columns and rows for a grid + of numColumns columns. + """ if numColumns <= 0: return if self.__data.isDirty: self.__data.updateLayoutCache() for index in range(len(self.__data.itemSizeHints)): - row = int(index/numColumns) + row = int(index / numColumns) col = index % numColumns size = self.__data.itemSizeHints[index] if col == 0: @@ -188,15 +271,17 @@ def layoutGrid(self, numColumns, rowHeight, colWidth): colWidth[col] = size.width() else: colWidth[col] = max([colWidth[col], size.width()]) - + def hasHeightForWidth(self): + """Return true: QwtDynGridLayout implements heightForWidth().""" return True - + def heightForWidth(self, width): + """Return The preferred height for this layout, given a width.""" if self.isEmpty(): return 0 numColumns = self.columnsForWidth(width) - numRows = int(self.itemCount()/numColumns) + numRows = int(self.itemCount() / numColumns) if self.itemCount() % numColumns: numRows += 1 rowHeight = [0] * numRows @@ -204,42 +289,55 @@ def heightForWidth(self, width): self.layoutGrid(numColumns, rowHeight, colWidth) margins = self.contentsMargins() margin_h = margins.top() + margins.bottom() - return margin_h+(numRows-1)*self.spacing()+sum(rowHeight) - + return margin_h + (numRows - 1) * self.spacing() + sum(rowHeight) + def stretchGrid(self, rect, numColumns, rowHeight, colWidth): + """ + Stretch columns in case of expanding() & QSizePolicy::Horizontal and + rows in case of expanding() & QSizePolicy::Vertical to fill the entire + rect. Rows and columns are stretched with the same factor. + """ if numColumns == 0 or self.isEmpty(): return expandH = self.expandingDirections() & Qt.Horizontal expandV = self.expandingDirections() & Qt.Vertical + margins = self.contentsMargins() + wmargins = margins.left() + margins.right() + hmargins = margins.top() + margins.bottom() if expandH: - xDelta = rect.width()-2*self.margin()-(numColumns-1)*self.spacing() + xDelta = rect.width() - wmargins - (numColumns - 1) * self.spacing() for col in range(numColumns): xDelta -= colWidth[col] if xDelta > 0: for col in range(numColumns): - space = xDelta/(numColumns-col) + space = xDelta // (numColumns - col) colWidth[col] += space xDelta -= space if expandV: - numRows = self.itemCount()/numColumns + numRows = self.itemCount() / numColumns if self.itemCount() % numColumns: numRows += 1 - yDelta = rect.height()-2*self.margin()-(numRows-1)*self.spacing() + yDelta = rect.height() - hmargins - (numRows - 1) * self.spacing() for row in range(numRows): yDelta -= rowHeight[row] if yDelta > 0: for row in range(numRows): - space = yDelta/(numRows-row) + space = yDelta // (numRows - row) rowHeight[row] += space yDelta -= space - + def sizeHint(self): + """ + Return the size hint. If maxColumns() > 0 it is the size for + a grid with maxColumns() columns, otherwise it is the size for + a grid with only one row. + """ if self.isEmpty(): return QSize() numColumns = self.itemCount() if self.__data.maxColumns > 0: numColumns = min([self.__data.maxColumns, numColumns]) - numRows = int(self.itemCount()/numColumns) + numRows = int(self.itemCount() / numColumns) if self.itemCount() % numColumns: numRows += 1 rowHeight = [0] * numRows @@ -248,12 +346,14 @@ def sizeHint(self): margins = self.contentsMargins() margin_h = margins.top() + margins.bottom() margin_w = margins.left() + margins.right() - h = margin_h+(numRows-1)*self.spacing()+sum(rowHeight) - w = margin_w+(numColumns-1)*self.spacing()+sum(colWidth) + h = margin_h + (numRows - 1) * self.spacing() + sum(rowHeight) + w = margin_w + (numColumns - 1) * self.spacing() + sum(colWidth) return QSize(w, h) - + def numRows(self): + """Return Number of rows of the current layout.""" return self.__data.numRows - + def numColumns(self): + """Return Number of columns of the current layout.""" return self.__data.numColumns diff --git a/qwt/graphic.py b/qwt/graphic.py index 09aab8c..9e18a4b 100644 --- a/qwt/graphic.py +++ b/qwt/graphic.py @@ -5,14 +5,44 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.null_paintdevice import QwtNullPaintDevice -from qwt.painter_command import QwtPainterCommand +""" +QwtGraphic +---------- + +.. autoclass:: QwtGraphic + :members: +""" + +import math + +from qtpy.QtCore import QObject, QPointF, QRect, QRectF, QSize, QSizeF, Qt +from qtpy.QtGui import ( + QImage, + QPaintEngine, + QPainter, + QPainterPathStroker, + QPixmap, + QTransform, +) -from qwt.qt.QtGui import (QPainter, QPainterPathStroker, QPaintEngine, QPixmap, - QTransform, QImage) -from qwt.qt.QtCore import Qt, QRectF, QSizeF, QSize, QPointF, QRect +from qwt.null_paintdevice import QwtNullPaintDevice +from qwt.painter_command import QwtPainterCommand, _flag_int -import numpy as np +# See painter_command.py for the rationale: cache the QPaintEngine.DirtyXxx +# flags as plain ints so the State-replay branch below does plain int bitwise +# tests instead of going through Python's enum.Flag.__and__ on PyQt6. +_DIRTY_PEN = _flag_int(QPaintEngine.DirtyPen) +_DIRTY_BRUSH = _flag_int(QPaintEngine.DirtyBrush) +_DIRTY_BRUSH_ORIGIN = _flag_int(QPaintEngine.DirtyBrushOrigin) +_DIRTY_FONT = _flag_int(QPaintEngine.DirtyFont) +_DIRTY_BACKGROUND = _flag_int(QPaintEngine.DirtyBackground) +_DIRTY_TRANSFORM = _flag_int(QPaintEngine.DirtyTransform) +_DIRTY_CLIP_ENABLED = _flag_int(QPaintEngine.DirtyClipEnabled) +_DIRTY_CLIP_REGION = _flag_int(QPaintEngine.DirtyClipRegion) +_DIRTY_CLIP_PATH = _flag_int(QPaintEngine.DirtyClipPath) +_DIRTY_HINTS = _flag_int(QPaintEngine.DirtyHints) +_DIRTY_COMPOSITION_MODE = _flag_int(QPaintEngine.DirtyCompositionMode) +_DIRTY_OPACITY = _flag_int(QPaintEngine.DirtyOpacity) def qwtHasScalablePen(painter): @@ -20,10 +50,6 @@ def qwtHasScalablePen(painter): scalablePen = False if pen.style() != Qt.NoPen and pen.brush().style() != Qt.NoBrush: scalablePen = not pen.isCosmetic() - if not scalablePen and pen.widthF() == 0.: - hints = painter.renderHints() - if hints & QPainter.NonCosmeticDefaultPen: - scalablePen = True return scalablePen @@ -47,13 +73,11 @@ def qwtStrokedPathRect(painter, path): def qwtExecCommand(painter, cmd, renderHints, transform, initialTransform): if cmd.type() == QwtPainterCommand.Path: doMap = False - if bool(renderHints & QwtGraphic.RenderPensUnscaled)\ - and painter.transform().isScaling(): + if ( + bool(renderHints & QwtGraphic.RenderPensUnscaled) + and painter.transform().isScaling() + ): isCosmetic = painter.pen().isCosmetic() - if isCosmetic and painter.pen().widthF() == 0.: - hints = painter.renderHints() - if hints & QPainter.NonCosmeticDefaultPen: - isCosmetic = False doMap = not isCosmetic if doMap: tr = painter.transform() @@ -75,35 +99,36 @@ def qwtExecCommand(painter, cmd, renderHints, transform, initialTransform): painter.drawImage(data.rect, data.image, data.subRect, data.flags) elif cmd.type() == QwtPainterCommand.State: data = cmd.stateData() - if data.flags & QPaintEngine.DirtyPen: + flags = _flag_int(data.flags) + if flags & _DIRTY_PEN: painter.setPen(data.pen) - if data.flags & QPaintEngine.DirtyBrush: + if flags & _DIRTY_BRUSH: painter.setBrush(data.brush) - if data.flags & QPaintEngine.DirtyBrushOrigin: + if flags & _DIRTY_BRUSH_ORIGIN: painter.setBrushOrigin(data.brushOrigin) - if data.flags & QPaintEngine.DirtyFont: + if flags & _DIRTY_FONT: painter.setFont(data.font) - if data.flags & QPaintEngine.DirtyBackground: + if flags & _DIRTY_BACKGROUND: painter.setBackgroundMode(data.backgroundMode) painter.setBackground(data.backgroundBrush) - if data.flags & QPaintEngine.DirtyTransform: + if flags & _DIRTY_TRANSFORM: painter.setTransform(data.transform) - if data.flags & QPaintEngine.DirtyClipEnabled: + if flags & _DIRTY_CLIP_ENABLED: painter.setClipping(data.isClipEnabled) - if data.flags & QPaintEngine.DirtyClipRegion: + if flags & _DIRTY_CLIP_REGION: painter.setClipRegion(data.clipRegion, data.clipOperation) - if data.flags & QPaintEngine.DirtyClipPath: + if flags & _DIRTY_CLIP_PATH: painter.setClipPath(data.clipPath, data.clipOperation) - if data.flags & QPaintEngine.DirtyHints: - for hint in (QPainter.Antialiasing, - QPainter.TextAntialiasing, - QPainter.SmoothPixmapTransform, - QPainter.HighQualityAntialiasing, - QPainter.NonCosmeticDefaultPen): + if flags & _DIRTY_HINTS: + for hint in ( + QPainter.Antialiasing, + QPainter.TextAntialiasing, + QPainter.SmoothPixmapTransform, + ): painter.setRenderHint(hint, bool(data.renderHints & hint)) - if data.flags & QPaintEngine.DirtyCompositionMode: + if flags & _DIRTY_COMPOSITION_MODE: painter.setCompositionMode(data.compositionMode) - if data.flags & QPaintEngine.DirtyOpacity: + if flags & _DIRTY_OPACITY: painter.setOpacity(data.opacity) @@ -117,11 +142,13 @@ def __init__(self, *args): self.__boundingRect = boundingRect self.__scalablePen = scalablePen else: - raise TypeError("%s() takes 0 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s() takes 0 or 3 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + def scaledBoundingRect(self, sx, sy, scalePens): - if sx == 1. and sy == 1.: + if sx == 1.0 and sy == 1.0: return self.__boundingRect transform = QTransform() transform.scale(sx, sy) @@ -129,46 +156,55 @@ def scaledBoundingRect(self, sx, sy, scalePens): rect = transform.mapRect(self.__boundingRect) else: rect = transform.mapRect(self.__pointRect) - l = abs(self.__pointRect.left()-self.__boundingRect.left()) - r = abs(self.__pointRect.right()-self.__boundingRect.right()) - t = abs(self.__pointRect.top()-self.__boundingRect.top()) - b = abs(self.__pointRect.bottom()-self.__boundingRect.bottom()) - rect.adjust(-l, -t, r, b) + left_diff = abs(self.__pointRect.left() - self.__boundingRect.left()) + right_diff = abs(self.__pointRect.right() - self.__boundingRect.right()) + top_diff = abs(self.__pointRect.top() - self.__boundingRect.top()) + bottom_diff = abs(self.__pointRect.bottom() - self.__boundingRect.bottom()) + rect.adjust(-left_diff, -top_diff, right_diff, bottom_diff) return rect - + def scaleFactorX(self, pathRect, targetRect, scalePens): if pathRect.width() <= 0.0: - return 0. + return 0.0 p0 = self.__pointRect.center() - l = abs(pathRect.left()-p0.x()) - r = abs(pathRect.right()-p0.x()) - w = 2.*min([l, r])*targetRect.width()/pathRect.width() + left_diff = abs(pathRect.left() - p0.x()) + r = abs(pathRect.right() - p0.x()) + w = 2.0 * min([left_diff, r]) * targetRect.width() / pathRect.width() if scalePens and self.__scalablePen: - sx = w/self.__boundingRect.width() + sx = w / self.__boundingRect.width() else: - pw = max([abs(self.__boundingRect.left()-self.__pointRect.left()), - abs(self.__boundingRect.right()-self.__pointRect.right())]) - sx = (w-2*pw)/self.__pointRect.width() + pw = max( + [ + abs(self.__boundingRect.left() - self.__pointRect.left()), + abs(self.__boundingRect.right() - self.__pointRect.right()), + ] + ) + sx = (w - 2 * pw) / self.__pointRect.width() return sx - + def scaleFactorY(self, pathRect, targetRect, scalePens): if pathRect.height() <= 0.0: - return 0. + return 0.0 p0 = self.__pointRect.center() - t = abs(pathRect.top()-p0.y()) - b = abs(pathRect.bottom()-p0.y()) - h = 2.*min([t, b])*targetRect.height()/pathRect.height() + t = abs(pathRect.top() - p0.y()) + b = abs(pathRect.bottom() - p0.y()) + h = 2.0 * min([t, b]) * targetRect.height() / pathRect.height() if scalePens and self.__scalablePen: - sy = h/self.__boundingRect.height() + sy = h / self.__boundingRect.height() else: - pw = max([abs(self.__boundingRect.top()-self.__pointRect.top()), - abs(self.__boundingRect.bottom()-self.__pointRect.bottom())]) - sy = (h-2*pw)/self.__pointRect.height() + pw = max( + [ + abs(self.__boundingRect.top() - self.__pointRect.top()), + abs(self.__boundingRect.bottom() - self.__pointRect.bottom()), + ] + ) + sy = (h - 2 * pw) / self.__pointRect.height() return sy - -class QwtGraphic_PrivateData(object): + +class QwtGraphic_PrivateData(QObject): def __init__(self): + QObject.__init__(self) self.boundingRect = QRectF(0.0, 0.0, -1.0, -1.0) self.pointRect = QRectF(0.0, 0.0, -1.0, -1.0) self.initialTransform = None @@ -179,97 +215,285 @@ def __init__(self): class QwtGraphic(QwtNullPaintDevice): - + """ + A paint device for scalable graphics + + `QwtGraphic` is the representation of a graphic that is tailored for + scalability. Like `QPicture` it will be initialized by `QPainter` + operations and can be replayed later to any target paint device. + + While the usual image representations `QImage` and `QPixmap` are not + scalable `Qt` offers two paint devices, that might be candidates + for representing a vector graphic: + + - `QPicture`: + + Unfortunately `QPicture` had been forgotten, when Qt4 + introduced floating point based render engines. Its API + is still on integers, what make it unusable for proper scaling. + + - `QSvgRenderer`, `QSvgGenerator`: + + Unfortunately `QSvgRenderer` hides to much information about + its nodes in internal APIs, that are necessary for proper + layout calculations. Also it is derived from `QObject` and + can't be copied like `QImage`/`QPixmap`. + + `QwtGraphic` maps all scalable drawing primitives to a `QPainterPath` + and stores them together with the painter state changes + ( pen, brush, transformation ... ) in a list of `QwtPaintCommands`. + For being a complete `QPaintDevice` it also stores pixmaps or images, + what is somehow against the idea of the class, because these objects + can't be scaled without a loss in quality. + + The main issue about scaling a `QwtGraphic` object are the pens used for + drawing the outlines of the painter paths. While non cosmetic pens + ( `QPen.isCosmetic()` ) are scaled with the same ratio as the path, + cosmetic pens have a fixed width. A graphic might have paths with + different pens - cosmetic and non-cosmetic. + + `QwtGraphic` caches 2 different rectangles: + + - control point rectangle: + + The control point rectangle is the bounding rectangle of all + control point rectangles of the painter paths, or the target + rectangle of the pixmaps/images. + + - bounding rectangle: + + The bounding rectangle extends the control point rectangle by + what is needed for rendering the outline with an unscaled pen. + + Because the offset for drawing the outline depends on the shape + of the painter path ( the peak of a triangle is different than the flat side ) + scaling with a fixed aspect ratio always needs to be calculated from the + control point rectangle. + + .. py:class:: QwtGraphic() + + Initializes a null graphic + + .. py:class:: QwtGraphic(other) + :noindex: + + Copy constructor + + :param qwt.graphic.QwtGraphic other: Source + """ + # enum RenderHint RenderPensUnscaled = 0x1 - + def __init__(self, *args): QwtNullPaintDevice.__init__(self) if len(args) == 0: self.setMode(QwtNullPaintDevice.PathMode) self.__data = QwtGraphic_PrivateData() elif len(args) == 1: - other, = args + (other,) = args self.setMode(other.mode()) self.__data = other.__data else: - raise TypeError("%s() takes 0 or 1 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s() takes 0 or 1 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + def reset(self): + """Clear all stored commands""" self.__data.commands = [] self.__data.pathInfos = [] self.__data.boundingRect = QRectF(0.0, 0.0, -1.0, -1.0) self.__data.pointRect = QRectF(0.0, 0.0, -1.0, -1.0) self.__data.defaultSize = QSizeF() - + def isNull(self): + """Return True, when no painter commands have been stored""" return len(self.__data.commands) == 0 - + def isEmpty(self): + """Return True, when the bounding rectangle is empty""" return self.__data.boundingRect.isEmpty() - + def setRenderHint(self, hint, on=True): + """Toggle an render hint""" if on: self.__data.renderHints |= hint else: self.__data.renderHints &= ~hint - + def testRenderHint(self, hint): + """Test a render hint""" return bool(self.__data.renderHints & hint) - + def boundingRect(self): + """ + The bounding rectangle is the :py:meth:`controlPointRect` + extended by the areas needed for rendering the outlines + with unscaled pens. + + :return: Bounding rectangle of the graphic + + .. seealso:: + + :py:meth:`controlPointRect`, :py:meth:`scaledBoundingRect` + """ if self.__data.boundingRect.width() < 0: return QRectF() return self.__data.boundingRect - + def controlPointRect(self): + """ + The control point rectangle is the bounding rectangle + of all control points of the paths and the target + rectangles of the images/pixmaps. + + :return: Control point rectangle + + .. seealso:: + + :py:meth:`boundingRect()`, :py:meth:`scaledBoundingRect()` + """ if self.__data.pointRect.width() < 0: return QRectF() return self.__data.pointRect - + def scaledBoundingRect(self, sx, sy): - if sx == 1. and sy == 1.: + """ + Calculate the target rectangle for scaling the graphic + + :param float sx: Horizontal scaling factor + :param float sy: Vertical scaling factor + :return: Scaled bounding rectangle + + .. note:: + + In case of paths that are painted with a cosmetic pen + (see :py:meth:`QPen.isCosmetic()`) the target rectangle is + different to multiplying the bounding rectangle. + + .. seealso:: + + :py:meth:`boundingRect()`, :py:meth:`controlPointRect()` + """ + if sx == 1.0 and sy == 1.0: return self.__data.boundingRect transform = QTransform() transform.scale(sx, sy) rect = transform.mapRect(self.__data.pointRect) for pathInfo in self.__data.pathInfos: - rect |= pathInfo.scaledBoundingRect(sx, sy, - not bool(self.__data.renderHints & self.RenderPensUnscaled)) + rect |= pathInfo.scaledBoundingRect( + sx, sy, not bool(self.__data.renderHints & self.RenderPensUnscaled) + ) return rect - + def sizeMetrics(self): + """Return Ceiled :py:meth:`defaultSize()`""" sz = self.defaultSize() - return QSize(np.ceil(sz.width()), np.ceil(sz.height())) - + return QSize(math.ceil(sz.width()), math.ceil(sz.height())) + def setDefaultSize(self, size): - w = max([0., size.width()]) - h = max([0., size.height()]) + """ + The default size is used in all methods rendering the graphic, + where no size is explicitly specified. Assigning an empty size + means, that the default size will be calculated from the bounding + rectangle. + + :param QSizeF size: Default size + + .. seealso:: + + :py:meth:`defaultSize()`, :py:meth:`boundingRect()` + """ + w = max([0.0, size.width()]) + h = max([0.0, size.height()]) self.__data.defaultSize = QSizeF(w, h) - + def defaultSize(self): + """ + When a non empty size has been assigned by setDefaultSize() this + size will be returned. Otherwise the default size is the size + of the bounding rectangle. + + The default size is used in all methods rendering the graphic, + where no size is explicitly specified. + + :return: Default size + + .. seealso:: + + :py:meth:`setDefaultSize()`, :py:meth:`boundingRect()` + """ if not self.__data.defaultSize.isEmpty(): return self.__data.defaultSize return self.boundingRect().size() - + def render(self, *args): + """ + .. py:method:: render(painter) + :noindex: + + Replay all recorded painter commands + + :param QPainter painter: Qt painter + + .. py:method:: render(painter, size, aspectRatioMode) + :noindex: + + Replay all recorded painter commands + + The graphic is scaled to fit into the rectangle + of the given size starting at ( 0, 0 ). + + :param QPainter painter: Qt painter + :param QSizeF size: Size for the scaled graphic + :param Qt.AspectRatioMode aspectRatioMode: Mode how to scale + + .. py:method:: render(painter, rect, aspectRatioMode) + :noindex: + + Replay all recorded painter commands + + The graphic is scaled to fit into the given rectangle + + :param QPainter painter: Qt painter + :param QRectF rect: Rectangle for the scaled graphic + :param Qt.AspectRatioMode aspectRatioMode: Mode how to scale + + .. py:method:: render(painter, pos, aspectRatioMode) + :noindex: + + Replay all recorded painter commands + + The graphic is scaled to the :py:meth:`defaultSize()` and aligned + to a position. + + :param QPainter painter: Qt painter + :param QPointF pos: Reference point, where to render + :param Qt.AspectRatioMode aspectRatioMode: Mode how to scale + """ if len(args) == 1: - painter, = args + (painter,) = args if self.isNull(): return transform = painter.transform() painter.save() for command in self.__data.commands: - qwtExecCommand(painter, command, self.__data.renderHints, - transform, self.__data.initialTransform) + qwtExecCommand( + painter, + command, + self.__data.renderHints, + transform, + self.__data.initialTransform, + ) painter.restore() elif len(args) in (2, 3) and isinstance(args[1], QSizeF): painter, size = args[:2] aspectRatioMode = Qt.IgnoreAspectRatio if len(args) == 3: aspectRatioMode = args[-1] - r = QRectF(0., 0., size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) self.render(painter, r, aspectRatioMode) elif len(args) in (2, 3) and isinstance(args[1], QRectF): painter, rect = args[:2] @@ -278,19 +502,19 @@ def render(self, *args): aspectRatioMode = args[-1] if self.isEmpty() or rect.isEmpty(): return - sx = 1. - sy = 1. - if self.__data.pointRect.width() > 0.: - sx = rect.width()/self.__data.pointRect.width() - if self.__data.pointRect.height() > 0.: - sy = rect.height()/self.__data.pointRect.height() + sx = 1.0 + sy = 1.0 + if self.__data.pointRect.width() > 0.0: + sx = rect.width() / self.__data.pointRect.width() + if self.__data.pointRect.height() > 0.0: + sy = rect.height() / self.__data.pointRect.height() scalePens = not bool(self.__data.renderHints & self.RenderPensUnscaled) for info in self.__data.pathInfos: ssx = info.scaleFactorX(self.__data.pointRect, rect, scalePens) - if ssx > 0.: + if ssx > 0.0: sx = min([sx, ssx]) ssy = info.scaleFactorY(self.__data.pointRect, rect, scalePens) - if ssy > 0.: + if ssy > 0.0: sy = min([sy, ssy]) if aspectRatioMode == Qt.KeepAspectRatio: s = min([sx, sy]) @@ -301,26 +525,26 @@ def render(self, *args): sx = s sy = s tr = QTransform() - tr.translate(rect.center().x()-.5*sx*self.__data.pointRect.width(), - rect.center().y()-.5*sy*self.__data.pointRect.height()) + tr.translate( + rect.center().x() - 0.5 * sx * self.__data.pointRect.width(), + rect.center().y() - 0.5 * sy * self.__data.pointRect.height(), + ) tr.scale(sx, sy) - tr.translate(-self.__data.pointRect.x(), - -self.__data.pointRect.y()) + tr.translate(-self.__data.pointRect.x(), -self.__data.pointRect.y()) transform = painter.transform() if not scalePens and transform.isScaling(): # we don't want to scale pens according to sx/sy, - # but we want to apply the scaling from the + # but we want to apply the scaling from the # painter transformation later self.__data.initialTransform = QTransform() - self.__data.initialTransform.scale(transform.m11(), - transform.m22()) + self.__data.initialTransform.scale(transform.m11(), transform.m22()) painter.setTransform(tr, True) self.render(painter) painter.setTransform(transform) self.__data.initialTransform = None elif len(args) in (2, 3) and isinstance(args[1], QPointF): painter, pos = args[:2] - alignment = Qt.AlignTop|Qt.AlignLeft + alignment = Qt.AlignTop | Qt.AlignLeft if len(args) == 3: alignment = args[-1] r = QRectF(pos, self.defaultSize()) @@ -338,19 +562,36 @@ def render(self, *args): r.moveBottom(pos.y()) self.render(painter, r) else: - raise TypeError("%s().render() takes 1, 2 or 3 argument(s) (%s "\ - "given)" % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s().render() takes 1, 2 or 3 argument(s) (%s " + "given)" % (self.__class__.__name__, len(args)) + ) + def toPixmap(self, *args): + """ + Convert the graphic to a `QPixmap` + + All pixels of the pixmap get initialized by `Qt.transparent` + before the graphic is scaled and rendered on it. + + The size of the pixmap is the default size ( ceiled to integers ) + of the graphic. + + :return: The graphic as pixmap in default size + + .. seealso:: + + :py:meth:`defaultSize()`, :py:meth:`toImage()`, :py:meth:`render()` + """ if len(args) == 0: if self.isNull(): return QPixmap() sz = self.defaultSize() - w = np.ceil(sz.width()) - h = np.ceil(sz.height()) + w = math.ceil(sz.width()) + h = math.ceil(sz.height()) pixmap = QPixmap(w, h) pixmap.fill(Qt.transparent) - r = QRectF(0., 0., sz.width(), sz.height()) + r = QRectF(0.0, 0.0, sz.width(), sz.height()) painter = QPainter(pixmap) self.render(painter, r, Qt.KeepAspectRatio) painter.end() @@ -367,14 +608,48 @@ def toPixmap(self, *args): self.render(painter, r, aspectRatioMode) painter.end() return pixmap - + def toImage(self, *args): + """ + .. py:method:: toImage() + :noindex: + + Convert the graphic to a `QImage` + + All pixels of the image get initialized by 0 ( transparent ) + before the graphic is scaled and rendered on it. + + The format of the image is `QImage.Format_ARGB32_Premultiplied`. + + The size of the image is the default size ( ceiled to integers ) + of the graphic. + + :return: The graphic as image in default size + + .. py:method:: toImage(size, [aspectRatioMode=Qt.IgnoreAspectRatio]) + :noindex: + + Convert the graphic to a `QImage` + + All pixels of the image get initialized by 0 ( transparent ) + before the graphic is scaled and rendered on it. + + The format of the image is `QImage.Format_ARGB32_Premultiplied`. + + :param QSize size: Size of the image + :param `Qt.AspectRatioMode` aspectRatioMode: Aspect ratio how to scale the graphic + :return: The graphic as image + + .. seealso:: + + :py:meth:`toPixmap()`, :py:meth:`render()` + """ if len(args) == 0: if self.isNull(): return QImage() sz = self.defaultSize() - w = np.ceil(sz.width()) - h = np.ceil(sz.height()) + w = math.ceil(sz.width()) + h = math.ceil(sz.height()) image = QImage(w, h, QImage.Format_ARGB32) image.fill(0) r = QRect(0, 0, sz.width(), sz.height()) @@ -393,8 +668,17 @@ def toImage(self, *args): painter = QPainter(image) self.render(painter, r, aspectRatioMode) return image - + def drawPath(self, path): + """ + Store a path command in the command list + + :param QPainterPath path: Painter path + + .. seealso:: + + :py:meth:`QPaintEngine.drawPath()` + """ painter = self.paintEngine().painter() if painter is None: return @@ -403,15 +687,29 @@ def drawPath(self, path): scaledPath = painter.transform().map(path) pointRect = scaledPath.boundingRect() boundingRect = QRectF(pointRect) - if painter.pen().style() != Qt.NoPen\ - and painter.pen().brush().style() != Qt.NoBrush: + if ( + painter.pen().style() != Qt.NoPen + and painter.pen().brush().style() != Qt.NoBrush + ): boundingRect = qwtStrokedPathRect(painter, path) self.updateControlPointRect(pointRect) self.updateBoundingRect(boundingRect) - self.__data.pathInfos += [PathInfo(pointRect, boundingRect, - qwtHasScalablePen(painter))] - + self.__data.pathInfos += [ + PathInfo(pointRect, boundingRect, qwtHasScalablePen(painter)) + ] + def drawPixmap(self, rect, pixmap, subRect): + """ + Store a pixmap command in the command list + + :param QRectF rect: target rectangle + :param QPixmap pixmap: Pixmap to be painted + :param QRectF subRect: Reactangle of the pixmap to be painted + + .. seealso:: + + :py:meth:`QPaintEngine.drawPixmap()` + """ painter = self.paintEngine().painter() if painter is None: return @@ -419,8 +717,20 @@ def drawPixmap(self, rect, pixmap, subRect): r = painter.transform().mapRect(rect) self.updateControlPointRect(r) self.updateBoundingRect(r) - + def drawImage(self, rect, image, subRect, flags): + """ + Store a image command in the command list + + :param QRectF rect: target rectangle + :param QImage image: Pixmap to be painted + :param QRectF subRect: Reactangle of the pixmap to be painted + :param Qt.ImageConversionFlags flags: Pixmap to be painted + + .. seealso:: + + :py:meth:`QPaintEngine.drawImage()` + """ painter = self.paintEngine().painter() if painter is None: return @@ -428,33 +738,41 @@ def drawImage(self, rect, image, subRect, flags): r = painter.transform().mapRect(rect) self.updateControlPointRect(r) self.updateBoundingRect(r) - + def updateState(self, state): - #XXX: shall we call the parent's implementation of updateState? + """ + Store a state command in the command list + + :param QPaintEngineState state: State to be stored + + .. seealso:: + + :py:meth:`QPaintEngine.updateState()` + """ + # XXX: shall we call the parent's implementation of updateState? self.__data.commands += [QwtPainterCommand(state)] - + def updateBoundingRect(self, rect): br = QRectF(rect) painter = self.paintEngine().painter() if painter and painter.hasClipping(): - #XXX: there's something fishy about the following lines... cr = painter.clipRegion().boundingRect() - cr = painter.transform().mapRect(br) + cr = painter.transform().mapRect(cr) br &= cr if self.__data.boundingRect.width() < 0: self.__data.boundingRect = br else: self.__data.boundingRect |= br - + def updateControlPointRect(self, rect): - if self.__data.pointRect.width() < 0.: + if self.__data.pointRect.width() < 0.0: self.__data.pointRect = rect else: self.__data.pointRect |= rect - + def commands(self): return self.__data.commands - + def setCommands(self, commands): self.reset() painter = QPainter(self) diff --git a/qwt/interval.py b/qwt/interval.py index 7b97fb3..bd2a5df 100644 --- a/qwt/interval.py +++ b/qwt/interval.py @@ -5,124 +5,242 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) +""" +QwtInterval +----------- + +.. autoclass:: QwtInterval + :members: +""" + class QwtInterval(object): - + """ + A class representing an interval + + The interval is represented by 2 doubles, the lower and the upper limit. + + .. py:class:: QwtInterval(minValue=0., maxValue=-1., borderFlags=None) + + Build an interval with from min/max values + + :param float minValue: Minimum value + :param float maxValue: Maximum value + :param int borderFlags: Include/Exclude borders + """ + # enum BorderFlag IncludeBorders = 0x00 ExcludeMinimum = 0x01 ExcludeMaximum = 0x02 ExcludeBorders = ExcludeMinimum | ExcludeMaximum - - def __init__(self, minValue=0., maxValue=-1., borderFlags=None): + + def __init__(self, minValue=0.0, maxValue=-1.0, borderFlags=None): assert not isinstance(minValue, QwtInterval) assert not isinstance(maxValue, QwtInterval) - self.__minValue = minValue - self.__maxValue = maxValue + self.__minValue = None + self.__maxValue = None + self.__borderFlags = None + self.setInterval(minValue, maxValue, borderFlags) + + def setInterval(self, minValue, maxValue, borderFlags=None): + """ + Assign the limits of the interval + + :param float minValue: Minimum value + :param float maxValue: Maximum value + :param int borderFlags: Include/Exclude borders + """ + self.__minValue = float(minValue) # avoid overflows with NumPy scalars + self.__maxValue = float(maxValue) # avoid overflows with NumPy scalars if borderFlags is None: self.__borderFlags = self.IncludeBorders else: self.__borderFlags = borderFlags - def setInterval(self, minValue, maxValue, borderFlags): - self.__minValue = minValue - self.__maxValue = maxValue - self.__borderFlags = borderFlags - def setBorderFlags(self, borderFlags): + """ + Change the border flags + + :param int borderFlags: Include/Exclude borders + + .. seealso:: + + :py:meth:`borderFlags()` + """ self.__borderFlags = borderFlags def borderFlags(self): + """ + :return: Border flags + + .. seealso:: + + :py:meth:`setBorderFlags()` + """ return self.__borderFlags - + def setMinValue(self, minValue): - self.__minValue = minValue - + """ + Assign the lower limit of the interval + + :param float minValue: Minimum value + """ + self.__minValue = float(minValue) # avoid overflows with NumPy scalars + def setMaxValue(self, maxValue): - self.__maxValue = maxValue - + """ + Assign the upper limit of the interval + + :param float maxValue: Maximum value + """ + self.__maxValue = float(maxValue) # avoid overflows with NumPy scalars + def minValue(self): + """ + :return: Lower limit of the interval + """ return self.__minValue - + def maxValue(self): + """ + :return: Upper limit of the interval + """ return self.__maxValue def isValid(self): + """ + A interval is valid when minValue() <= maxValue(). + In case of `QwtInterval.ExcludeBorders` it is true + when minValue() < maxValue() + + :return: True, when the interval is valid + """ if (self.__borderFlags & self.ExcludeBorders) == 0: return self.__minValue <= self.__maxValue else: return self.__minValue < self.__maxValue - + def width(self): - if self.isValid: + """ + The width of invalid intervals is 0.0, otherwise the result is + maxValue() - minValue(). + + :return: the width of an interval + """ + if self.isValid(): return self.__maxValue - self.__minValue else: - return 0. - + return 0.0 + def __and__(self, other): return self.intersect(other) - + def __iand__(self, other): self = self & other return self - + def __or__(self, other): if isinstance(other, QwtInterval): return self.unite(other) else: return self.extend(other) - + def __ior__(self, other): self = self | other return self - + def __eq__(self, other): - return self.__minValue == other.__minValue and\ - self.__maxValue == other.__maxValue and\ - self.__borderFlags == other.__borderFlags + return ( + self.__minValue == other.__minValue + and self.__maxValue == other.__maxValue + and self.__borderFlags == other.__borderFlags + ) def __ne__(self, other): return not self.__eq__(other) - + def isNull(self): + """ + :return: true, if isValid() && (minValue() >= maxValue()) + """ return self.isValid() and self.__minValue >= self.__maxValue - + def invalidate(self): - self.__minValue = 0. - self.__maxValue = -1. - + """ + The limits are set to interval [0.0, -1.0] + + .. seealso:: + + :py:meth:`isValid()` + """ + self.__minValue = 0.0 + self.__maxValue = -1.0 + def normalized(self): + """ + Normalize the limits of the interval + + If maxValue() < minValue() the limits will be inverted. + + :return: Normalized interval + + .. seealso:: + + :py:meth:`isValid()`, :py:meth:`inverted()` + """ if self.__minValue > self.__maxValue: return self.inverted() - elif self.__minValue == self.__maxValue and\ - self.__borderFlags == self.ExcludeMinimum: + elif ( + self.__minValue == self.__maxValue + and self.__borderFlags == self.ExcludeMinimum + ): return self.inverted() else: return self - + def inverted(self): + """ + Invert the limits of the interval + + :return: Inverted interval + + .. seealso:: + + :py:meth:`normalized()` + """ borderFlags = self.IncludeBorders if self.__borderFlags & self.ExcludeMinimum: borderFlags |= self.ExcludeMaximum if self.__borderFlags & self.ExcludeMaximum: borderFlags |= self.ExcludeMinimum return QwtInterval(self.__maxValue, self.__minValue, borderFlags) - + def contains(self, value): + """ + Test if a value is inside an interval + + :param float value: Value + :return: true, if value >= minValue() && value <= maxValue() + """ if not self.isValid(): return False elif value < self.__minValue or value > self.__maxValue: return False - elif value == self.__minValue and\ - self.__borderFlags & self.ExcludeMinimum: + elif value == self.__minValue and self.__borderFlags & self.ExcludeMinimum: return False - elif value == self.__maxValue and\ - self.__borderFlags & self.ExcludeMaximum: + elif value == self.__maxValue and self.__borderFlags & self.ExcludeMaximum: return False else: return True def unite(self, other): + """ + Unite two intervals + + :param qwt.interval.QwtInterval other: other interval to united with + :return: united interval + """ if not self.isValid(): if not other.isValid(): return QwtInterval() @@ -130,10 +248,10 @@ def unite(self, other): return other elif not other.isValid(): return self - + united = QwtInterval() flags = self.IncludeBorders - + # minimum if self.__minValue < other.minValue(): united.setMinValue(self.__minValue) @@ -143,9 +261,8 @@ def unite(self, other): flags &= other.borderFlags() & self.ExcludeMinimum else: united.setMinValue(self.__minValue) - flags &= (self.__borderFlags & other.borderFlags())\ - & self.ExcludeMinimum - + flags &= (self.__borderFlags & other.borderFlags()) & self.ExcludeMinimum + # maximum if self.__maxValue > other.maxValue(): united.setMaxValue(self.__maxValue) @@ -155,39 +272,46 @@ def unite(self, other): flags &= other.borderFlags() & self.ExcludeMaximum else: united.setMaxValue(self.__maxValue) - flags &= self.__borderFlags & other.borderFlags()\ - & self.ExcludeMaximum - + flags &= self.__borderFlags & other.borderFlags() & self.ExcludeMaximum + united.setBorderFlags(flags) return united - + def intersect(self, other): + """ + Intersect two intervals + + :param qwt.interval.QwtInterval other: other interval to intersect with + :return: intersected interval + """ if not other.isValid() or not self.isValid(): return QwtInterval() - + i1 = self i2 = other - + if i1.minValue() > i2.minValue(): i1, i2 = i2, i1 elif i1.minValue() == i2.minValue(): if i1.borderFlags() & self.ExcludeMinimum: i1, i2 = i2, i1 - + if i1.maxValue() < i2.maxValue(): return QwtInterval() - + if i1.maxValue() == i2.minValue(): - if i1.borderFlags() & self.ExcludeMaximum or\ - i2.borderFlags() & self.ExcludeMinimum: + if ( + i1.borderFlags() & self.ExcludeMaximum + or i2.borderFlags() & self.ExcludeMinimum + ): return QwtInterval() - + intersected = QwtInterval() flags = self.IncludeBorders - + intersected.setMinValue(i2.minValue()) flags |= i2.borderFlags() & self.ExcludeMinimum - + if i1.maxValue() < i2.maxValue(): intersected.setMaxValue(i1.maxValue()) flags |= i1.borderFlags() & self.ExcludeMaximum @@ -197,38 +321,58 @@ def intersect(self, other): else: # i1.maxValue() == i2.maxValue() intersected.setMaxValue(i1.maxValue()) flags |= i1.borderFlags() & i2.borderFlags() & self.ExcludeMaximum - + intersected.setBorderFlags(flags) return intersected - + def intersects(self, other): + """ + Test if two intervals overlap + + :param qwt.interval.QwtInterval other: other interval + :return: True, when the intervals are intersecting + """ if not other.isValid() or not self.isValid(): return False - + i1 = self i2 = other - + if i1.minValue() > i2.minValue(): i1, i2 = i2, i1 - elif i1.minValue() == i2.minValue() and\ - i1.borderFlags() & self.ExcludeMinimum: + elif i1.minValue() == i2.minValue() and i1.borderFlags() & self.ExcludeMinimum: i1, i2 = i2, i1 - + if i1.maxValue() > i2.minValue(): return True elif i1.maxValue() == i2.minValue(): - return i1.borderFlags() & self.ExcludeMaximum and\ - i2.borderFlags() & self.ExcludeMinimum + return ( + i1.borderFlags() & self.ExcludeMaximum + and i2.borderFlags() & self.ExcludeMinimum + ) return False - + def symmetrize(self, value): + """ + Adjust the limit that is closer to value, so that value becomes + the center of the interval. + + :param float value: Center + :return: Interval with value as center + """ if not self.isValid(): return self - delta = max([abs(value-self.__maxValue), - abs(value-self.__minValue)]) - return QwtInterval(value-delta, value+delta) + delta = max([abs(value - self.__maxValue), abs(value - self.__minValue)]) + return QwtInterval(value - delta, value + delta) def limited(self, lowerBound, upperBound): + """ + Limit the interval, keeping the border modes + + :param float lowerBound: Lower limit + :param float upperBound: Upper limit + :return: Limited interval + """ if not self.isValid() or lowerBound > upperBound: return QwtInterval() minValue = max([self.__minValue, lowerBound]) @@ -236,10 +380,19 @@ def limited(self, lowerBound, upperBound): maxValue = max([self.__maxValue, lowerBound]) maxValue = min([maxValue, upperBound]) return QwtInterval(minValue, maxValue, self.__borderFlags) - + def extend(self, value): + """ + Extend the interval + + If value is below minValue(), value becomes the lower limit. + If value is above maxValue(), value becomes the upper limit. + + extend() has no effect for invalid intervals + + :param float value: Value + :return: extended interval + """ if not self.isValid(): return self - return QwtInterval(min([value, self.__minValue]), - max([value, self.__maxValue])) - + return QwtInterval(min([value, self.__minValue]), max([value, self.__maxValue])) diff --git a/qwt/legend.py b/qwt/legend.py index 79de0f0..a117370 100644 --- a/qwt/legend.py +++ b/qwt/legend.py @@ -5,33 +5,479 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.legend_data import QwtLegendData +""" +QwtLegend +--------- + +.. autoclass:: QwtLegendData + :members: + +.. autoclass:: QwtLegendLabel + :members: + +.. autoclass:: QwtLegend + :members: +""" + +import math + +from qtpy.QtCore import QEvent, QObject, QPoint, QRect, QRectF, QSize, Qt, Signal + +# qDrawWinButton, +from qtpy.QtGui import QPainter, QPalette, QPixmap +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QScrollArea, + QStyle, + QStyleOption, + QVBoxLayout, + QWidget, +) + from qwt.dyngrid_layout import QwtDynGridLayout from qwt.painter import QwtPainter -from qwt.legend_label import QwtLegendLabel +from qwt.text import QwtText, QwtTextLabel + + +class QwtLegendData(object): + """ + Attributes of an entry on a legend + + `QwtLegendData` is an abstract container ( like `QAbstractModel` ) + to exchange attributes, that are only known between to + the plot item and the legend. + + By overloading `QwtPlotItem.legendData()` any other set of attributes + could be used, that can be handled by a modified ( or completely + different ) implementation of a legend. + + .. seealso:: + + :py:class:`qwt.legend.QwtLegend` + + .. note:: + + The stockchart example implements a legend as a tree + with checkable items + """ + + # enum Mode + ReadOnly, Clickable, Checkable = list(range(3)) + + # enum Role + ModeRole, TitleRole, IconRole = list(range(3)) + UserRole = 32 + + def __init__(self): + self.__map = {} + + def setValues(self, map_): + """ + Set the legend attributes + + :param dict map_: Values + + .. seealso:: + + :py:meth:`values()` + """ + self.__map = map_ + + def values(self): + """ + :return: Legend attributes + + .. seealso:: + + :py:meth:`setValues()` + """ + return self.__map + + def hasRole(self, role): + """ + :param int role: Attribute role + :return: True, when the internal map has an entry for role + """ + return role in self.__map + + def setValue(self, role, data): + """ + Set an attribute value + + :param int role: Attribute role + :param QVariant data: Attribute value + + .. seealso:: + + :py:meth:`value()` + """ + self.__map[role] = data + + def value(self, role): + """ + :param int role: Attribute role + :return: Attribute value for a specific role + + .. seealso:: + + :py:meth:`setValue()` + """ + return self.__map.get(role) + + def isValid(self): + """ + :return: True, when the internal map is empty + """ + return len(self.__map) != 0 + + def title(self): + """ + :return: Value of the TitleRole attribute + """ + titleValue = self.value(QwtLegendData.TitleRole) + if isinstance(titleValue, QwtText): + text = titleValue + else: + text = QwtText(titleValue) + return text + + def icon(self): + """ + :return: Value of the IconRole attribute + """ + return self.value(QwtLegendData.IconRole) + + def mode(self): + """ + :return: Value of the ModeRole attribute + """ + modeValue = self.value(QwtLegendData.ModeRole) + if isinstance(modeValue, int): + return modeValue + return QwtLegendData.ReadOnly + + +BUTTONFRAME = 2 +MARGIN = 2 + + +def buttonShift(w): + option = QStyleOption() + option.initFrom(w) + ph = w.style().pixelMetric(QStyle.PM_ButtonShiftHorizontal, option, w) + pv = w.style().pixelMetric(QStyle.PM_ButtonShiftVertical, option, w) + return QSize(ph, pv) + + +class QwtLegendLabel_PrivateData(QObject): + def __init__(self): + QObject.__init__(self) + + self.itemMode = QwtLegendData.ReadOnly + self.isDown = False + self.spacing = MARGIN + self.legendData = QwtLegendData() + self.icon = QPixmap() + + +class QwtLegendLabel(QwtTextLabel): + """A widget representing something on a QwtLegend.""" + + clicked = Signal() + pressed = Signal() + released = Signal() + checked = Signal(bool) + + def __init__(self, parent=None): + QwtTextLabel.__init__(self, parent) + self.__data = QwtLegendLabel_PrivateData() + self.setMargin(MARGIN) + self.setIndent(MARGIN) + + def setData(self, legendData): + """ + Set the attributes of the legend label -from qwt.qt.QtGui import (QFrame, QScrollArea, QWidget, QVBoxLayout, QPalette, - QApplication) -from qwt.qt.QtCore import Signal, QEvent, QSize, Qt, QRect, QRectF + :param QwtLegendData legendData: Attributes of the label -import numpy as np + .. seealso:: + + :py:meth:`data()` + """ + self.__data.legendData = legendData + doUpdate = self.updatesEnabled() + self.setUpdatesEnabled(False) + self.setText(legendData.title()) + icon = legendData.icon() + if icon is not None: + self.setIcon(icon.toPixmap()) + if legendData.hasRole(QwtLegendData.ModeRole): + self.setItemMode(legendData.mode()) + if doUpdate: + self.setUpdatesEnabled(True) + self.update() + + def data(self): + """ + :return: Attributes of the label + + .. seealso:: + + :py:meth:`setData()`, :py:meth:`qwt.plot.QwtPlotItem.legendData()` + """ + return self.__data.legendData + + def setText(self, text): + """ + Set the text to the legend item + + :param qwt.text.QwtText text: Text label + + .. seealso:: + + :py:meth:`text()` + """ + flags = Qt.AlignLeft | Qt.AlignVCenter | Qt.TextExpandTabs | Qt.TextWordWrap + text.setRenderFlags(flags) + QwtTextLabel.setText(self, text) + + def setItemMode(self, mode): + """ + Set the item mode. + The default is `QwtLegendData.ReadOnly`. + + :param int mode: Item mode + + .. seealso:: + + :py:meth:`itemMode()` + """ + if mode != self.__data.itemMode: + self.__data.itemMode = mode + self.__data.isDown = False + self.setFocusPolicy( + Qt.TabFocus if mode != QwtLegendData.ReadOnly else Qt.NoFocus + ) + self.setMargin(BUTTONFRAME + MARGIN) + self.updateGeometry() + + def itemMode(self): + """ + :return: Item mode + + .. seealso:: + + :py:meth:`setItemMode()` + """ + return self.__data.itemMode + + def setIcon(self, icon): + """ + Assign the icon + + :param QPixmap icon: Pixmap representing a plot item + + .. seealso:: + + :py:meth:`icon()`, :py:meth:`qwt.plot.QwtPlotItem.legendIcon()` + """ + self.__data.icon = icon + indent = self.margin() + self.__data.spacing + if icon.width() > 0: + indent += icon.width() + self.__data.spacing + self.setIndent(indent) + + def icon(self): + """ + :return: Pixmap representing a plot item + + .. seealso:: + + :py:meth:`setIcon()` + """ + return self.__data.icon + + def setSpacing(self, spacing): + """ + Change the spacing between icon and text + + :param int spacing: Spacing + + .. seealso:: + + :py:meth:`spacing()`, :py:meth:`qwt.text.QwtTextLabel.margin()` + """ + spacing = max([spacing, 0]) + if spacing != self.__data.spacing: + self.__data.spacing = spacing + mgn = self.contentsMargins() + margin = max([mgn.left(), mgn.top(), mgn.right(), mgn.bottom()]) + indent = margin + self.__data.spacing + if self.__data.icon.width() > 0: + indent += self.__data.icon.width() + self.__data.spacing + self.setIndent(indent) + + def spacing(self): + """ + :return: Spacing between icon and text + + .. seealso:: + + :py:meth:`setSpacing()` + """ + return self.__data.spacing + + def setChecked(self, on): + """ + Check/Uncheck a the item + + :param bool on: check/uncheck + + .. seealso:: + + :py:meth:`isChecked()`, :py:meth:`setItemMode()` + """ + if self.__data.itemMode == QwtLegendData.Checkable: + isBlocked = self.signalsBlocked() + self.blockSignals(True) + self.setDown(on) + self.blockSignals(isBlocked) + + def isChecked(self): + """ + :return: true, if the item is checked + + .. seealso:: + + :py:meth:`setChecked()` + """ + return self.__data.itemMode == QwtLegendData.Checkable and self.isDown() + + def setDown(self, down): + """ + Set the item being down + + :param bool on: true, if the item is down + + .. seealso:: + + :py:meth:`isDown()` + """ + if down == self.__data.isDown: + return + self.__data.isDown = down + self.update() + if self.__data.itemMode == QwtLegendData.Clickable: + if self.__data.isDown: + self.pressed.emit() + else: + self.released.emit() + self.clicked.emit() + if self.__data.itemMode == QwtLegendData.Checkable: + self.checked.emit(self.__data.isDown) + + def isDown(self): + """ + :return: true, if the item is down + + .. seealso:: + + :py:meth:`setDown()` + """ + return self.__data.isDown + + def sizeHint(self): + """ + :return: a size hint + """ + sz = QwtTextLabel.sizeHint(self) + sz.setHeight(max([sz.height(), self.__data.icon.height() + 4])) + if self.__data.itemMode != QwtLegendData.ReadOnly: + sz += buttonShift(self) + return sz + + def paintEvent(self, e): + cr = self.contentsRect() + painter = QPainter(self) + painter.setClipRegion(e.region()) + # if self.__data.isDown: + # qDrawWinButton( + # painter, 0, 0, self.width(), self.height(), self.palette(), True + # ) + painter.save() + if self.__data.isDown: + shiftSize = buttonShift(self) + painter.translate(shiftSize.width(), shiftSize.height()) + painter.setClipRect(cr) + self.drawContents(painter) + if not self.__data.icon.isNull(): + iconRect = QRect(cr) + iconRect.setX(iconRect.x() + self.margin()) + if self.__data.itemMode != QwtLegendData.ReadOnly: + iconRect.setX(iconRect.x() + BUTTONFRAME) + iconRect.setSize(self.__data.icon.size()) + iconRect.moveCenter(QPoint(iconRect.center().x(), cr.center().y())) + painter.drawPixmap(iconRect, self.__data.icon) + painter.restore() + + def mousePressEvent(self, e): + if e.button() == Qt.LeftButton: + if self.__data.itemMode == QwtLegendData.Clickable: + self.setDown(True) + return + elif self.__data.itemMode == QwtLegendData.Checkable: + self.setDown(not self.isDown()) + return + QwtTextLabel.mousePressEvent(self, e) + + def mouseReleaseEvent(self, e): + if e.button() == Qt.LeftButton: + if self.__data.itemMode == QwtLegendData.Clickable: + self.setDown(False) + return + elif self.__data.itemMode == QwtLegendData.Checkable: + return + QwtTextLabel.mouseReleaseEvent(self, e) + + def keyPressEvent(self, e): + if e.key() == Qt.Key_Space: + if self.__data.itemMode == QwtLegendData.Clickable: + if not e.isAutoRepeat(): + self.setDown(True) + return + elif self.__data.itemMode == QwtLegendData.Checkable: + if not e.isAutoRepeat(): + self.setDown(not self.isDown()) + return + QwtTextLabel.keyPressEvent(self, e) + + def keyReleaseEvent(self, e): + if e.key() == Qt.Key_Space: + if self.__data.itemMode == QwtLegendData.Clickable: + if not e.isAutoRepeat(): + self.setDown(False) + return + elif self.__data.itemMode == QwtLegendData.Checkable: + return + QwtTextLabel.keyReleaseEvent(self, e) class QwtAbstractLegend(QFrame): def __init__(self, parent): QFrame.__init__(self, parent) - + def renderLegend(self, painter, rect, fillBackground): raise NotImplementedError - + def isEmpty(self): return 0 - + def scrollExtent(self, orientation): return 0 - + def updateLegend(self, itemInfo, data): - raise NotImplementedError + raise NotImplementedError class Entry(object): @@ -39,13 +485,14 @@ def __init__(self): self.itemInfo = None self.widgets = [] + class QwtLegendMap(object): def __init__(self): self.__entries = [] - + def isEmpty(self): return len(self.__entries) == 0 - + def insert(self, itemInfo, widgets): for entry in self.__entries: if entry.itemInfo == itemInfo: @@ -55,36 +502,35 @@ def insert(self, itemInfo, widgets): newEntry.itemInfo = itemInfo newEntry.widgets = widgets self.__entries += [newEntry] - + def remove(self, itemInfo): for entry in self.__entries[:]: if entry.itemInfo == itemInfo: self.__entries.remove(entry) return - + def removeWidget(self, widget): for entry in self.__entries: while widget in entry.widgets: entry.widgets.remove(widget) - + def itemInfo(self, widget): if widget is not None: for entry in self.__entries: if widget in entry.widgets: return entry.itemInfo - + def legendWidgets(self, itemInfo): if itemInfo is not None: for entry in self.__entries: if entry.itemInfo == itemInfo: return entry.widgets return [] - + class LegendView(QScrollArea): def __init__(self, parent): QScrollArea.__init__(self, parent) - self.gridLayout = None self.contentsWidget = QWidget(self) self.contentsWidget.setObjectName("QwtLegendViewContents") self.setWidget(self.contentsWidget) @@ -92,7 +538,7 @@ def __init__(self, parent): self.viewport().setObjectName("QwtLegendViewport") self.contentsWidget.setAutoFillBackground(False) self.viewport().setAutoFillBackground(False) - + def event(self, event): if event.type() == QEvent.PolishRequest: self.setFocusPolicy(Qt.NoFocus) @@ -105,13 +551,13 @@ def event(self, event): h = self.contentsWidget.heightForWidth(w) self.contentsWidget.resize(w, h) return QScrollArea.event(self, event) - + def viewportEvent(self, event): ok = QScrollArea.viewportEvent(self, event) if event.type() == QEvent.Resize: self.layoutContents() return ok - + def viewportSize(self, w, h): sbHeight = self.horizontalScrollBar().sizeHint().height() sbWidth = self.verticalScrollBar().sizeHint().width() @@ -126,34 +572,82 @@ def viewportSize(self, w, h): if w > vw and vh == ch: vh -= sbHeight return QSize(vw, vh) - + def layoutContents(self): - tl = self.gridLayout - if tl is None: + layout = self.contentsWidget.layout() + if layout is None: return visibleSize = self.viewport().contentsRect().size() - margins = tl.contentsMargins() + margins = layout.contentsMargins() margin_w = margins.left() + margins.right() - minW = int(tl.maxItemWidth()+margin_w) + minW = int(layout.maxItemWidth() + margin_w) w = max([visibleSize.width(), minW]) - h = max([tl.heightForWidth(w), visibleSize.height()]) + h = max([layout.heightForWidth(w), visibleSize.height()]) vpWidth = self.viewportSize(w, h).width() if w > vpWidth: w = max([vpWidth, minW]) - h = max([tl.heightForWidth(w), visibleSize.height()]) + h = max([layout.heightForWidth(w), visibleSize.height()]) self.contentsWidget.resize(w, h) - -class QwtLegend_PrivateData(object): + +class QwtLegend_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.itemMode = QwtLegendData.ReadOnly - self.view = None - self.itemMap = QwtLegendMap() + self.view = QwtDynGridLayout() + self.itemMap = QwtLegendMap() + class QwtLegend(QwtAbstractLegend): - SIG_CLICKED = Signal("PyQt_PyObject", int) - SIG_CHECKED = Signal("PyQt_PyObject", bool, int) - + """ + The legend widget + + The QwtLegend widget is a tabular arrangement of legend items. Legend + items might be any type of widget, but in general they will be + a QwtLegendLabel. + + .. seealso :: + + :py:class`qwt.legend.QwtLegendLabel`, + :py:class`qwt.plot.QwtPlotItem`, + :py:class`qwt.plot.QwtPlot` + + .. py:class:: QwtLegend([parent=None]) + + Constructor + + :param QWidget parent: Parent widget + + .. py:data:: clicked + + A signal which is emitted when the user has clicked on + a legend label, which is in `QwtLegendData.Clickable` mode. + + :param itemInfo: Info for the item item of the selected legend item + :param index: Index of the legend label in the list of widgets that are associated with the plot item + + .. note:: + + Clicks are disabled as default + + .. py:data:: checked + + A signal which is emitted when the user has clicked on + a legend label, which is in `QwtLegendData.Checkable` mode + + :param itemInfo: Info for the item of the selected legend label + :param index: Index of the legend label in the list of widgets that are associated with the plot item + :param on: True when the legend label is checked + + .. note:: + + Clicks are disabled as default + """ + + clicked = Signal(object, int) + checked = Signal(object, bool, int) + def __init__(self, parent=None): QwtAbstractLegend.__init__(self, parent) self.setFrameStyle(QFrame.NoFrame) @@ -162,40 +656,116 @@ def __init__(self, parent=None): self.__data.view.setObjectName("QwtLegendView") self.__data.view.setFrameStyle(QFrame.NoFrame) gridLayout = QwtDynGridLayout(self.__data.view.contentsWidget) - gridLayout.setAlignment(Qt.AlignHCenter|Qt.AlignTop) + gridLayout.setAlignment(Qt.AlignHCenter | Qt.AlignTop) self.__data.view.gridLayout = gridLayout self.__data.view.contentsWidget.installEventFilter(self) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.__data.view) - + def setMaxColumns(self, numColumns): + """ + Set the maximum number of entries in a row + + F.e when the maximum is set to 1 all items are aligned + vertically. 0 means unlimited + + :param int numColumns: Maximum number of entries in a row + + .. seealso:: + + :py:meth:`maxColumns()`, + :py:meth:`QwtDynGridLayout.setMaxColumns()` + """ tl = self.__data.view.gridLayout if tl is not None: tl.setMaxColumns(numColumns) - + self.updateGeometry() + def maxColumns(self): + """ + :return: Maximum number of entries in a row + + .. seealso:: + + :py:meth:`setMaxColumns()`, + :py:meth:`QwtDynGridLayout.maxColumns()` + """ tl = self.__data.view.gridLayout if tl is not None: return tl.maxColumns() return 0 - + def setDefaultItemMode(self, mode): + """ + Set the default mode for legend labels + + Legend labels will be constructed according to the + attributes in a `QwtLegendData` object. When it doesn't + contain a value for the `QwtLegendData.ModeRole` the + label will be initialized with the default mode of the legend. + + :param int mode: Default item mode + + .. seealso:: + + :py:meth:`itemMode()`, + :py:meth:`QwtLegendData.value()`, + :py:meth:`QwtPlotItem::legendData()` + + ... note:: + + Changing the mode doesn't have any effect on existing labels. + """ self.__data.itemMode = mode - + def defaultItemMode(self): + """ + :return: Default item mode + + .. seealso:: + + :py:meth:`setDefaultItemMode()` + """ return self.__data.itemMode - + def contentsWidget(self): + """ + The contents widget is the only child of the viewport of + the internal `QScrollArea` and the parent widget of all legend + items. + + :return: Container widget of the legend items + """ return self.__data.view.contentsWidget - + def horizontalScrollBar(self): + """ + :return: Horizontal scrollbar + + .. seealso:: + + :py:meth:`verticalScrollBar()` + """ return self.__data.view.horizontalScrollBar() - + def verticalScrollBar(self): + """ + :return: Vertical scrollbar + + .. seealso:: + + :py:meth:`horizontalScrollBar()` + """ return self.__data.view.verticalScrollBar() - + def updateLegend(self, itemInfo, data): + """ + Update the entries for an item + + :param QVariant itemInfo: Info for an item + :param list data: Default item mode + """ widgetList = self.legendWidgets(itemInfo) if len(widgetList) != len(data): contentsLayout = self.__data.view.gridLayout @@ -218,21 +788,48 @@ def updateLegend(self, itemInfo, data): self.updateTabOrder() for i in range(len(data)): self.updateWidget(widgetList[i], data[i]) - + def createWidget(self, data): + """ + Create a widget to be inserted into the legend + + The default implementation returns a `QwtLegendLabel`. + + :param QwtLegendData data: Attributes of the legend entry + :return: Widget representing data on the legend + + ... note:: + + updateWidget() will called soon after createWidget() + with the same attributes. + """ label = QwtLegendLabel() label.setItemMode(self.defaultItemMode()) - label.SIG_CLICKED.connect(lambda: self.itemClicked(label)) - label.SIG_CHECKED.connect(lambda state: self.itemChecked(state, label)) + label.clicked.connect(lambda: self.itemClicked(label)) + label.checked.connect(lambda state: self.itemChecked(state, label)) return label - + def updateWidget(self, widget, data): - label = widget #TODO: cast to QwtLegendLabel! + """ + Update the widget + + :param QWidget widget: Usually a QwtLegendLabel + :param QwtLegendData data: Attributes to be displayed + + .. seealso:: + + :py:meth:`createWidget()` + + ... note:: + + When widget is no QwtLegendLabel updateWidget() does nothing. + """ + label = widget # TODO: cast to QwtLegendLabel! if label is not None: label.setData(data) if data.value(QwtLegendData.ModeRole) is None: label.setItemMode(self.defaultItemMode()) - + def updateTabOrder(self): contentsLayout = self.__data.view.gridLayout if contentsLayout is not None: @@ -242,35 +839,49 @@ def updateTabOrder(self): if w is not None and item.widget(): QWidget.setTabOrder(w, item.widget()) w = item.widget() - + def sizeHint(self): + """Return a size hint""" hint = self.__data.view.contentsWidget.sizeHint() - hint += QSize(2*self.frameWidth(), 2*self.frameWidth()) + hint += QSize(2 * self.frameWidth(), 2 * self.frameWidth()) return hint - + def heightForWidth(self, width): - width -= 2*self.frameWidth() + """ + :param int width: Width + :return: The preferred height, for a width. + """ + width -= 2 * self.frameWidth() h = self.__data.view.contentsWidget.heightForWidth(width) if h >= 0: - h += 2*self.frameWidth() + h += 2 * self.frameWidth() return h - + def eventFilter(self, object_, event): + """ + Handle QEvent.ChildRemoved andQEvent.LayoutRequest events + for the contentsWidget(). + + :param QObject object: Object to be filtered + :param QEvent event: Event + :return: Forwarded to QwtAbstractLegend.eventFilter() + """ if object_ is self.__data.view.contentsWidget: if event.type() == QEvent.ChildRemoved: - ce = event #TODO: cast to QChildEvent + ce = event # TODO: cast to QChildEvent if ce.child().isWidgetType(): - w = ce.child() #TODO: cast to QWidget + w = ce.child() # TODO: cast to QWidget self.__data.itemMap.removeWidget(w) elif event.type() == QEvent.LayoutRequest: self.__data.view.layoutContents() if self.parentWidget() and self.parentWidget().layout() is None: - QApplication.postEvent(self.parentWidget(), - QEvent(QEvent.LayoutRequest)) + QApplication.postEvent( + self.parentWidget(), QEvent(QEvent.LayoutRequest) + ) return QwtAbstractLegend.eventFilter(self, object_, event) - + def itemClicked(self, widget): -# w = self.sender() #TODO: cast to QWidget + # w = self.sender() #TODO: cast to QWidget w = widget if w is not None: itemInfo = self.__data.itemMap.itemInfo(w) @@ -278,10 +889,10 @@ def itemClicked(self, widget): widgetList = self.__data.itemMap.legendWidgets(itemInfo) if w in widgetList: index = widgetList.index(w) - self.SIG_CLICKED.emit(itemInfo, index) - + self.clicked.emit(itemInfo, index) + def itemChecked(self, on, widget): -# w = self.sender() #TODO: cast to QWidget + # w = self.sender() #TODO: cast to QWidget w = widget if w is not None: itemInfo = self.__data.itemMap.itemInfo(w) @@ -289,27 +900,30 @@ def itemChecked(self, on, widget): widgetList = self.__data.itemMap.legendWidgets(itemInfo) if w in widgetList: index = widgetList.index(w) - self.SIG_CHECKED.emit(itemInfo, on, index) - + self.checked.emit(itemInfo, on, index) + def renderLegend(self, painter, rect, fillBackground): + """ + Render the legend into a given rectangle. + + :param QPainter painter: Painter + :param QRectF rect: Bounding rectangle + :param bool fillBackground: When true, fill rect with the widget background + """ if self.__data.itemMap.isEmpty(): return if fillBackground: - if self.autoFillBackground() or\ - self.testAttribute(Qt.WA_StyledBackground): + if self.autoFillBackground() or self.testAttribute(Qt.WA_StyledBackground): QwtPainter.drawBackground(painter, rect, self) -# const QwtDynGridLayout *legendLayout = -# qobject_cast( contentsWidget()->layout() ); - #TODO: not the exact same implementation legendLayout = self.__data.view.contentsWidget.layout() if legendLayout is None: return - left, right, top, bottom = self.getContentsMargins() + margins = self.layout().contentsMargins() layoutRect = QRect() - layoutRect.setLeft(np.ceil(rect.left())+left) - layoutRect.setTop(np.ceil(rect.top())+top) - layoutRect.setRight(np.ceil(rect.right())-right) - layoutRect.setBottom(np.ceil(rect.bottom())-bottom) + layoutRect.setLeft(math.ceil(rect.left()) + margins.left()) + layoutRect.setTop(math.ceil(rect.top()) + margins.top()) + layoutRect.setRight(math.ceil(rect.right()) - margins.right()) + layoutRect.setBottom(math.ceil(rect.bottom()) - margins.bottom()) numCols = legendLayout.columnsForWidth(layoutRect.width()) itemRects = legendLayout.layoutItems(layoutRect, numCols) index = 0 @@ -322,36 +936,66 @@ def renderLegend(self, painter, rect, fillBackground): self.renderItem(painter, w, itemRects[index], fillBackground) index += 1 painter.restore() - + def renderItem(self, painter, widget, rect, fillBackground): + """ + Render a legend entry into a given rectangle. + + :param QPainter painter: Painter + :param QWidget widget: Widget representing a legend entry + :param QRectF rect: Bounding rectangle + :param bool fillBackground: When true, fill rect with the widget background + """ if fillBackground: - if widget.autoFillBackground() or\ - widget.testAttribute(Qt.WA_StyledBackground): + if widget.autoFillBackground() or widget.testAttribute( + Qt.WA_StyledBackground + ): QwtPainter.drawBackground(painter, rect, widget) - label = widget #TODO: cast to QwtLegendLabel + label = widget # TODO: cast to QwtLegendLabel if label is not None: icon = label.data().icon() sz = icon.defaultSize() - iconRect = QRectF(rect.x()+label.margin(), - rect.center().y()-.5*sz.height(), - sz.width(), sz.height()) + mgn = label.contentsMargins() + margin = max([mgn.left(), mgn.top(), mgn.right(), mgn.bottom()]) + iconRect = QRectF( + rect.x() + margin, + rect.center().y() - 0.5 * sz.height(), + sz.width(), + sz.height(), + ) icon.render(painter, iconRect, Qt.KeepAspectRatio) titleRect = QRectF(rect) - titleRect.setX(iconRect.right()+2*label.spacing()) + titleRect.setX(iconRect.right() + 2 * label.spacing()) painter.setFont(label.font()) painter.setPen(label.palette().color(QPalette.Text)) - label.drawText(painter, titleRect) #TODO: cast label to QwtLegendLabel - + label.drawText(painter, titleRect) # TODO: cast label to QwtLegendLabel + def legendWidgets(self, itemInfo): + """ + List of widgets associated to a item + + :param QVariant itemInfo: Info about an item + """ return self.__data.itemMap.legendWidgets(itemInfo) - + def legendWidget(self, itemInfo): + """ + First widget in the list of widgets associated to an item + + :param QVariant itemInfo: Info about an item + """ list_ = self.__data.itemMap.legendWidgets(itemInfo) if list_: return list_[0] - + def itemInfo(self, widget): + """ + Find the item that is associated to a widget + + :param QWidget widget: Widget on the legend + :return: Associated item info + """ return self.__data.itemMap.itemInfo(widget) - + def isEmpty(self): return self.__data.itemMap.isEmpty() diff --git a/qwt/legend_data.py b/qwt/legend_data.py deleted file mode 100644 index eecc603..0000000 --- a/qwt/legend_data.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.text import QwtText - - -class QwtLegendData(object): - - # enum Mode - ReadOnly, Clickable, Checkable = list(range(3)) - - # enum Role - ModeRole, TitleRole, IconRole = list(range(3)) - UserRole = 32 - - def __init__(self): - self.__map = {} - - def setValues(self, map_): - self.__map = map_ - - def values(self): - return self.__map - - def hasRole(self, role): - return role in self.__map - - def setValue(self, role, data): - self.__map[role] = data - - def value(self, role): - return self.__map.get(role) - - def isValid(self): - return len(self.__map) != 0 - - def title(self): - titleValue = self.value(QwtLegendData.TitleRole) - if isinstance(titleValue, QwtText): - text = titleValue - else: - text.setText(titleValue) - return text - - def icon(self): - return self.value(QwtLegendData.IconRole) - - def mode(self): - modeValue = self.value(QwtLegendData.ModeRole) - if isinstance(modeValue, int): - return modeValue - return QwtLegendData.ReadOnly diff --git a/qwt/legend_label.py b/qwt/legend_label.py deleted file mode 100644 index 98c67ca..0000000 --- a/qwt/legend_label.py +++ /dev/null @@ -1,204 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.text import QwtTextLabel -from qwt.legend_data import QwtLegendData - -from qwt.qt.QtGui import (QStyleOption, QStyle, QPixmap, QApplication, - QPainter, qDrawWinButton) -from qwt.qt.QtCore import Signal, Qt, QSize, QRect, QPoint - - -BUTTONFRAME = 2 -MARGIN = 2 - - -def buttonShift(w): - option = QStyleOption() - option.initFrom(w) - ph = w.style().pixelMetric(QStyle.PM_ButtonShiftHorizontal, option, w) - pv = w.style().pixelMetric(QStyle.PM_ButtonShiftVertical, option, w) - return QSize(ph, pv) - - -class QwtLegendLabel_PrivateData(object): - def __init__(self): - self.itemMode = QwtLegendData.ReadOnly - self.isDown = False - self.spacing = MARGIN - self.legendData = QwtLegendData() - self.icon = QPixmap() - - -class QwtLegendLabel(QwtTextLabel): - SIG_CLICKED = Signal() - SIG_PRESSED = Signal() - SIG_RELEASED = Signal() - SIG_CHECKED = Signal(bool) - - def __init__(self, parent=None): - QwtTextLabel.__init__(self, parent) - self.__data = QwtLegendLabel_PrivateData() - self.setMargin(MARGIN) - self.setIndent(MARGIN) - - def setData(self, legendData): - self.__data.legendData = legendData - doUpdate = self.updatesEnabled() - self.setUpdatesEnabled(False) - self.setText(legendData.title()) - icon = legendData.icon() - if icon is not None: - self.setIcon(icon.toPixmap()) - if legendData.hasRole(QwtLegendData.ModeRole): - self.setItemMode(legendData.mode()) - if doUpdate: - self.setUpdatesEnabled(True) - self.update() - - def data(self): - return self.__data.legendData - - def setText(self, text): - flags = Qt.AlignLeft|Qt.AlignVCenter|Qt.TextExpandTabs|Qt.TextWordWrap - txt = text #TODO: WTF? - txt.setRenderFlags(flags) - QwtTextLabel.setText(self, text) - - def setItemMode(self, mode): - if mode != self.__data.itemMode: - self.__data.itemMode = mode - self.__data.isDown = False - self.setFocusPolicy(Qt.TabFocus if mode != QwtLegendData.ReadOnly - else Qt.NoFocus) - self.setMargin(BUTTONFRAME+MARGIN) - self.updateGeometry() - - def itemMode(self): - return self.__data.itemMode - - def setIcon(self, icon): - self.__data.icon = icon - indent = self.margin()+self.__data.spacing - if icon.width() > 0: - indent += icon.width()+self.__data.spacing - self.setIndent(indent) - - def icon(self): - return self.__data.icon - - def setSpacing(self, spacing): - spacing = max([spacing, 0]) - if spacing != self.__data.spacing: - self.__data.spacing = spacing - indent = self.margin()+self.__data.spacing - if self.__data.icon.width() > 0: - indent += self.__data.icon.width()+self.__data.spacing - self.setIndent(indent) - - def spacing(self): - return self.__data.spacing - - def setChecked(self, on): - if self.__data.itemMode == QwtLegendData.Checkable: - isBlocked = self.signalsBlocked() - self.blockSignals(True) - self.setDown(on) - self.blockSignals(isBlocked) - - def isChecked(self): - return self.__data.itemMode == QwtLegendData.Checkable and self.isDown() - - def setDown(self, down): - if down == self.__data.isDown: - return - self.__data.isDown = down - self.update() - if self.__data.itemMode == QwtLegendData.Clickable: - if self.__data.isDown: - self.SIG_PRESSED.emit() - else: - self.SIG_RELEASED.emit() - self.SIG_CLICKED.emit() - if self.__data.itemMode == QwtLegendData.Checkable: - self.SIG_CHECKED.emit(self.__data.isDown) - - def isDown(self): - return self.__data.isDown - - def sizeHint(self): - sz = QwtTextLabel.sizeHint(self) - sz.setHeight(max([sz.height(), self.__data.icon.height()+4])) - if self.__data.itemMode != QwtLegendData.ReadOnly: - sz += buttonShift(self) - sz = sz.expandedTo(QApplication.globalStrut()) - return sz - - def paintEvent(self, e): - cr = self.contentsRect() - painter = QPainter(self) - painter.setClipRegion(e.region()) - if self.__data.isDown: - qDrawWinButton(painter, 0, 0, self.width(), self.height(), - self.palette(), True) - painter.save() - if self.__data.isDown: - shiftSize = buttonShift(self) - painter.translate(shiftSize.width(), shiftSize.height()) - painter.setClipRect(cr) - self.drawContents(painter) - if not self.__data.icon.isNull(): - iconRect = QRect(cr) - iconRect.setX(iconRect.x()+self.margin()) - if self.__data.itemMode != QwtLegendData.ReadOnly: - iconRect.setX(iconRect.x()+BUTTONFRAME) - iconRect.setSize(self.__data.icon.size()) - iconRect.moveCenter(QPoint(iconRect.center().x(), - cr.center().y())) - painter.drawPixmap(iconRect, self.__data.icon) - painter.restore() - - def mousePressEvent(self, e): - if e.button() == Qt.LeftButton: - if self.__data.itemMode == QwtLegendData.Clickable: - self.setDown(True) - return - elif self.__data.itemMode == QwtLegendData.Checkable: - self.setDown(not self.isDown()) - return - QwtTextLabel.mousePressEvent(self, e) - - def mouseReleaseEvent(self, e): - if e.button() == Qt.LeftButton: - if self.__data.itemMode == QwtLegendData.Clickable: - self.setDown(False) - return - elif self.__data.itemMode == QwtLegendData.Checkable: - return - QwtTextLabel.mouseReleaseEvent(self, e) - - def keyPressEvent(self, e): - if e.key() == Qt.Key_Space: - if self.__data.itemMode == QwtLegendData.Clickable: - if not e.isAutoRepeat(): - self.setDown(True) - return - elif self.__data.itemMode == QwtLegendData.Checkable: - if not e.isAutoRepeat(): - self.setDown(not self.isDown()) - return - QwtTextLabel.keyPressEvent(self, e) - - def keyReleaseEvent(self, e): - if e.key() == Qt.Key_Space: - if self.__data.itemMode == QwtLegendData.Clickable: - if not e.isAutoRepeat(): - self.setDown(False) - return - elif self.__data.itemMode == QwtLegendData.Checkable: - return - QwtTextLabel.keyReleaseEvent(self, e) diff --git a/qwt/null_paintdevice.py b/qwt/null_paintdevice.py index 9535241..80d0877 100644 --- a/qwt/null_paintdevice.py +++ b/qwt/null_paintdevice.py @@ -5,31 +5,45 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.qt.QtGui import QPaintEngine, QPainterPath, QPaintDevice +""" +QwtNullPaintDevice +------------------ +.. autoclass:: QwtNullPaintDevice + :members: +""" -class QwtNullPaintDevice_PrivateData(object): +import os + +from qtpy.QtCore import QObject +from qtpy.QtGui import QPaintDevice, QPaintEngine, QPainterPath + +QT_API = os.environ["QT_API"] + + +class QwtNullPaintDevice_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.mode = QwtNullPaintDevice.NormalMode class QwtNullPaintDevice_PaintEngine(QPaintEngine): def __init__(self, paintdevice): - super(QwtNullPaintDevice_PaintEngine, self - ).__init__(QPaintEngine.AllFeatures) + super(QwtNullPaintDevice_PaintEngine, self).__init__(QPaintEngine.AllFeatures) self.__paintdevice = paintdevice - + def begin(self, paintdevice): self.setActive(True) return True - + def end(self): self.setActive(False) return True - + def type(self): return QPaintEngine.User - + def drawRects(self, rects, rectCount=None): if rectCount is None: rectCount = len(rects) @@ -40,41 +54,39 @@ def drawRects(self, rects, rectCount=None): try: QPaintEngine.drawRects(self, rects, rectCount) except TypeError: - # PyQt <=4.9 QPaintEngine.drawRects(self, rects) return device.drawRects(rects, rectCount) - + def drawLines(self, lines, lineCount=None): if lineCount is None: lineCount = len(lines) device = self.nullDevice() if device is None: return - if device.mode() != QwtNullPaintDevice.NormalMode: + if device.mode() != QwtNullPaintDevice.NormalMode and QT_API.startswith("pyqt"): try: - QPaintEngine.drawLines(lines, lineCount) + QPaintEngine.drawLines(self, lines, lineCount) except TypeError: - # PyQt <=4.9 QPaintEngine.drawLines(self, lines) return device.drawLines(lines, lineCount) - + def drawEllipse(self, rect): device = self.nullDevice() if device is None: return if device.mode() != QwtNullPaintDevice.NormalMode: - QPaintEngine.drawEllipse(rect) + QPaintEngine.drawEllipse(self, rect) return device.drawEllipse(rect) - + def drawPath(self, path): device = self.nullDevice() if device is None: return device.drawPath(path) - + def drawPoints(self, points, pointCount=None): if pointCount is None: pointCount = len(points) @@ -83,13 +95,12 @@ def drawPoints(self, points, pointCount=None): return if device.mode() != QwtNullPaintDevice.NormalMode: try: - QPaintEngine.drawPoints(points, pointCount) + QPaintEngine.drawPoints(self, points, pointCount) except TypeError: - # PyQt <=4.9 QPaintEngine.drawPoints(self, points) return device.drawPoints(points, pointCount) - + def drawPolygon(self, *args): if len(args) == 3: points, pointCount, mode = args @@ -112,31 +123,31 @@ def drawPolygon(self, *args): device.drawPath(path) return device.drawPolygon(points, pointCount, mode) - + def drawPixmap(self, rect, pm, subRect): device = self.nullDevice() if device is None: return device.drawPixmap(rect, pm, subRect) - + def drawTextItem(self, pos, textItem): device = self.nullDevice() if device is None: return if device.mode() != QwtNullPaintDevice.NormalMode: - QPaintEngine.drawTextItem(pos, textItem) + QPaintEngine.drawTextItem(self, pos, textItem) return device.drawTextItem(pos, textItem) - + def drawTiledPixmap(self, rect, pixmap, subRect): device = self.nullDevice() if device is None: return if device.mode() != QwtNullPaintDevice.NormalMode: - QPaintEngine.drawTiledPixmap(rect, pixmap, subRect) + QPaintEngine.drawTiledPixmap(self, rect, pixmap, subRect) return device.drawTiledPixmap(rect, pixmap, subRect) - + def drawImage(self, rect, image, subRect, flags): device = self.nullDevice() if device is None: @@ -148,7 +159,7 @@ def updateState(self, state): if device is None: return device.updateState(state) - + def nullDevice(self): if not self.isActive(): return @@ -156,77 +167,148 @@ def nullDevice(self): class QwtNullPaintDevice(QPaintDevice): - + """ + A null paint device doing nothing + + Sometimes important layout/rendering geometries are not + available or changeable from the public Qt class interface. + ( f.e hidden in the style implementation ). + + `QwtNullPaintDevice` can be used to manipulate or filter out + this information by analyzing the stream of paint primitives. + + F.e. `QwtNullPaintDevice` is used by `QwtPlotCanvas` to identify + styled backgrounds with rounded corners. + + Modes: + + * `NormalMode`: + + All vector graphic primitives are painted by + the corresponding draw methods + + * `PolygonPathMode`: + + Vector graphic primitives ( beside polygons ) are mapped to a + `QPainterPath` and are painted by `drawPath`. In `PolygonPathMode` + mode only a few draw methods are called: + + - `drawPath()` + - `drawPixmap()` + - `drawImage()` + - `drawPolygon()` + + * `PathMode`: + + Vector graphic primitives are mapped to a `QPainterPath` + and are painted by `drawPath`. In `PathMode` mode + only a few draw methods are called: + + - `drawPath()` + - `drawPixmap()` + - `drawImage()` + """ + # enum Mode NormalMode, PolygonPathMode, PathMode = list(range(3)) - + def __init__(self): super(QwtNullPaintDevice, self).__init__() self.__engine = None self.__data = QwtNullPaintDevice_PrivateData() - + def setMode(self, mode): + """ + Set the render mode + + :param int mode: New mode + + .. seealso:: + + :py:meth:`mode()` + """ self.__data.mode = mode - + def mode(self): + """ + :return: Render mode + + .. seealso:: + + :py:meth:`setMode()` + """ return self.__data.mode - + def paintEngine(self): if self.__engine is None: self.__engine = QwtNullPaintDevice_PaintEngine(self) return self.__engine - + def metric(self, deviceMetric): if deviceMetric == QPaintDevice.PdmWidth: value = self.sizeMetrics().width() elif deviceMetric == QPaintDevice.PdmHeight: value = self.sizeMetrics().height() elif deviceMetric == QPaintDevice.PdmNumColors: - value = 0xffffffff + value = 0xFFFFFFFF elif deviceMetric == QPaintDevice.PdmDepth: value = 32 - elif deviceMetric in (QPaintDevice.PdmPhysicalDpiX, - QPaintDevice.PdmPhysicalDpiY, - QPaintDevice.PdmDpiY, QPaintDevice.PdmDpiX): + elif deviceMetric in ( + QPaintDevice.PdmPhysicalDpiX, + QPaintDevice.PdmPhysicalDpiY, + QPaintDevice.PdmDpiY, + QPaintDevice.PdmDpiX, + ): value = 72 elif deviceMetric == QPaintDevice.PdmWidthMM: - value = round(self.metric(QPaintDevice.PdmWidth)*25.4/self.metric(QPaintDevice.PdmDpiX)) + value = round( + self.metric(QPaintDevice.PdmWidth) + * 25.4 + / self.metric(QPaintDevice.PdmDpiX) + ) elif deviceMetric == QPaintDevice.PdmHeightMM: - value = round(self.metric(QPaintDevice.PdmHeight)*25.4/self.metric(QPaintDevice.PdmDpiY)) + value = round( + self.metric(QPaintDevice.PdmHeight) + * 25.4 + / self.metric(QPaintDevice.PdmDpiY) + ) + elif deviceMetric == QPaintDevice.PdmDevicePixelRatio: + value = 1 + elif deviceMetric == QPaintDevice.PdmDevicePixelRatioScaled: + value = 1 else: - value = 0 + value = super(QwtNullPaintDevice, self).metric(deviceMetric) return value - + def drawRects(self, rects, rectCount): pass - + def drawLines(self, lines, lineCount): pass - + def drawEllipse(self, rect): pass - + def drawPath(self, path): pass - + def drawPoints(self, points, pointCount): pass - + def drawPolygon(self, points, pointCount, mode): pass - + def drawPixmap(self, rect, pm, subRect): pass - + def drawTextItem(self, pos, textItem): pass - + def drawTiledPixmap(self, rect, pm, subRect): pass - + def drawImage(self, rect, image, subRect, flags): pass - + def updateState(self, state): pass - \ No newline at end of file diff --git a/qwt/painter.py b/qwt/painter.py index d08aa07..6179dfc 100644 --- a/qwt/painter.py +++ b/qwt/painter.py @@ -5,70 +5,38 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.clipper import QwtClipper -from qwt.color_map import QwtColorMap - -from qwt.qt.QtGui import (QPaintEngine, QApplication, QFont, QFontInfo, QFrame, - QPixmap, QPainter, QPolygonF, QPalette, QStyle, QPen, - QAbstractTextDocumentLayout, QStyleOptionFocusRect, - QBrush, QLinearGradient, QPainterPath, QColor, - QStyleOption, QPolygon, QTransform) -from qwt.qt.QtCore import (QSize, QRectF, Qt, QPointF, QSizeF, QRect, QPoint, - QT_VERSION) - -import numpy as np - -QWIDGETSIZE_MAX = (1<<24)-1 - - -def qwtIsClippingNeeded(painter): - doClipping = False - clipRect = QRectF() - #TODO: remove next line when QwtClipper will be implemented - return doClipping, clipRect - pe = painter.paintEngine() - if pe and pe.type() == QPaintEngine.SVG: - if painter.hasClipping(): - doClipping = True - clipRect = painter.clipRegion().boundingRect() - return doClipping, clipRect +""" +QwtPainterClass +--------------- +.. autoclass:: QwtPainterClass + :members: +""" -def qwtDrawPolyline(painter, points, pointCount, polylineSplitting): - doSplit = False - if polylineSplitting: - pe = painter.paintEngine() - if pe and pe.type() == QPaintEngine.Raster: - doSplit = True - if False:#doSplit: #FIXME: uncomment "doSplit", and fix associated bug (or solve performance issue...) - splitSize = 6 - for i in range(0, pointCount, splitSize): - n = min([splitSize+1, pointCount-i]) - painter.drawPolyline(points+i, n) - else: - painter.drawPolyline(points) - - -def qwtScreenResolution(): - screenResolution = QSize() - if not screenResolution.isValid(): - desktop = QApplication.desktop() - if desktop is not None: - screenResolution.setWidth(desktop.logicalDpiX()) - screenResolution.setHeight(desktop.logicalDpiY()) - return screenResolution +from qtpy.QtCore import QLineF, QPoint, QRect, Qt +from qtpy.QtGui import ( + QColor, + QLinearGradient, + QPaintEngine, + QPainter, + QPainterPath, + QPalette, + QPen, + QPixmap, + QRegion, +) +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QStyle, + QStyleOption, + QStyleOptionFocusRect, +) +from qwt.color_map import QwtColorMap +from qwt.scale_map import QwtScaleMap -def qwtUnscaleFont(painter): - if painter.font().pixelSize() >= 0: - return - screenResolution = qwtScreenResolution() - pd = painter.device() - if pd.logicalDpiX() != screenResolution.width() or\ - pd.logicalDpiY() != screenResolution.height(): - pixelFont = QFont(painter.font(), QApplication.desktop()) - pixelFont.setPixelSize(QFontInfo(pixelFont).pixelSize()) - painter.setFont(pixelFont) +QWIDGETSIZE_MAX = (1 << 24) - 1 def isX11GraphicsSystem(): @@ -79,290 +47,24 @@ def isX11GraphicsSystem(): return isX11 -class QwtPainterClass(object): - def __init__(self): - self.__polylineSplitting = True - self.__roundingAlignment = True - - def isAligning(self, painter): - if painter and painter.isActive(): - if painter.paintEngine().type() in (QPaintEngine.Pdf, - QPaintEngine.SVG): - return False - tr = painter.transform() - if tr.isRotating() or tr.isScaling(): - return False - return True - - def setRoundingAlignment(self, enable): - self.__roundingAlignment = enable - - def roundingAlignment(self, painter=None): - if painter is None: - return self.__roundingAlignment - else: - return self.__roundingAlignment and self.isAligning(painter) - - def setPolylineSplitting(self, enable): - self.__polylineSplitting = enable - - def polylineSplitting(self): - return self.__polylineSplitting - - def drawPath(self, painter, path): - painter.drawPath(path) - - def drawRect(self, *args): - if len(args) == 5: - painter, x, y, w, h = args - self.drawRect(painter, QRectF(x, y, w, h)) - elif len(args) == 2: - painter, rect = args - r = rect - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping: - if not clipRect.intersects(r): - return - if not clipRect.contains(r): - self.fillRect(painter, r & clipRect, painter.brush()) - painter.save() - painter.setBrush(Qt.NoBrush) - self.drawPolyline(painter, QPolygonF(r)) - painter.restore() - return - painter.drawRect(r) - else: - raise TypeError("QwtPainter.drawRect() takes 2 or 5 argument(s) "\ - "(%s given)" % len(args)) - - def fillRect(self, painter, rect, brush): - if not rect.isValid(): - return - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping: - clipRect &= painter.window() - else: - clipRect = painter.window() - if painter.hasClipping(): - clipRect &= painter.clipRegion().boundingRect() - r = rect - if deviceClipping: - r = r.intersected(clipRect) - if r.isValid(): - painter.fillRect(r, brush) - - def drawPie(self, painter, rect, a, alen): - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping and not clipRect.contains(rect): - return - painter.drawPie(rect, a, alen) - - def drawEllipse(self, painter, rect): - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping and not clipRect.contains(rect): - return - painter.drawEllipse(rect) - - def drawText(self, *args): - if len(args) == 4: - if isinstance(args[1], (QRectF, QRect)): - painter, rect, flags, text = args - painter.save() - qwtUnscaleFont(painter) - painter.drawText(rect, flags, text) - painter.restore() - else: - painter, x, y, text = args - self.drawText(painter, QPointF(x, y), text) - elif len(args) == 3: - painter, pos, text = args - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping and not clipRect.contains(pos): - return - painter.save() - qwtUnscaleFont(painter) - painter.drawText(pos, text) - painter.restore() - elif len(args) == 7: - painter, x, y, w, h, flags, text = args - self.drawText(painter, QRectF( x, y, w, h ), flags, text) - else: - raise TypeError("QwtPainter.drawText() takes 3, 4 or 7 argument"\ - "(s) (%s given)" % len(args)) - - def drawSimpleRichText(self, painter, rect, flags, text): - txt = text.clone() +def qwtFillRect(widget, painter, rect, brush): + if brush.style() == Qt.TexturePattern: + painter.save() + painter.setClipRect(rect) + painter.drawTiledPixmap(rect, brush.texture(), rect.topLeft()) + painter.restore() + elif brush.gradient(): painter.save() - unscaledRect = QRectF(rect) - if painter.font().pixelSize() < 0: - res = qwtScreenResolution() - pd = painter.device() - if pd.logicalDpiX() != res.width()\ - or pd.logicalDpiY() != res.height(): - transform = QTransform() - transform.scale(res.width()/float(pd.logicalDpiX()), - res.height()/float(pd.logicalDpiY())) - painter.setWorldTransform(transform, True) - invtrans, _ok = transform.inverted() - unscaledRect = invtrans.mapRect(rect) - txt.setDefaultFont(painter.font()) - txt.setPageSize(QSizeF(unscaledRect.width(), QWIDGETSIZE_MAX)) - layout = txt.documentLayout() - height = layout.documentSize().height() - y = unscaledRect.y() - if flags & Qt.AlignBottom: - y += unscaledRect.height()-height - elif flags & Qt.AlignVCenter: - y += (unscaledRect.height()-height)/2 - context = QAbstractTextDocumentLayout.PaintContext() - context.palette.setColor(QPalette.Text, painter.pen().color()) - painter.translate(unscaledRect.x(), y) - layout.draw(painter, context) + painter.setClipRect(rect) + painter.fillRect(0, 0, widget.width(), widget.height(), brush) painter.restore() - - def drawLine(self, *args): - if len(args) == 3: - painter, p1, p2 = args - if isinstance(p1, QPointF): - p1 = p1.toPoint() - if isinstance(p2, QPointF): - p2 = p2.toPoint() - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping and not clipRect.contains(p1)\ - and not clipRect.contains(p2): - polygon = QPolygonF() - polygon += p1 - polygon += p2 - self.drawPolyline(painter, polygon) - return - painter.drawLine(p1, p2) - elif len(args) == 5: - painter, x1, y1, x2, y2 = args - self.drawLine(painter, QPointF(x1, y1), QPointF(x2, y2)) - elif len(args) == 2: - painter, line = args - self.drawLine(painter, line.p1(), line.p2()) - else: - raise TypeError("QwtPainter.drawLine() takes 2, 3 or 5 argument"\ - "(s) (%s given)" % len(args)) - - def drawPolygon(self, painter, polygon): - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - cpa = polygon - if deviceClipping: - if isinstance(polygon, QPolygonF): - cpa = QwtClipper().clipPolygonF(clipRect, polygon) - else: - cpa = QwtClipper().clipPolygon(clipRect, polygon) - painter.drawPolygon(cpa) - - def drawPolyline(self, *args): - if len(args) == 2: - painter, polygon = args - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - cpa = polygon - if deviceClipping: - if isinstance(polygon, QPolygonF): - cpa = QwtClipper().clipPolygonF(clipRect, polygon) - else: - cpa = QwtClipper().clipPolygon(clipRect, polygon) - qwtDrawPolyline(painter, cpa, cpa.size(), - self.__polylineSplitting) - elif len(args) == 3: - painter, points, pointCount = args - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if deviceClipping: - if isinstance(points[0], QPointF): - polygon = QPolygonF(points) - polygon = QwtClipper().clipPolygonF(clipRect, polygon) - else: - polygon = QPolygon(points) - polygon = QwtClipper().clipPolygon(clipRect, polygon) - qwtDrawPolyline(painter, polygon, - polygon.size(), self.__polylineSplitting) -# polygon = QPolygonF(pointCount) -# pointer = polygon.data() -# pointer.setsize(pointCount*2*np.finfo(float).dtype.itemsize) -# memory = np.frombuffer(pointer, float) -# memory[0::2] = xdata -# memory[1::2] = ydata - else: - qwtDrawPolyline(painter, points, pointCount, - self.__polylineSplitting) - else: - raise TypeError("QwtPainter.drawPolyline() takes 2 or 3 argument"\ - "(s) (%s given)" % len(args)) - - def drawPoint(self, painter, pos): - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if isinstance(pos, QPointF): - if deviceClipping and not clipRect.contains(pos): - return - else: - if deviceClipping: - minX = np.ceil(clipRect.left()) - maxX = np.floor(clipRect.right()) - minY = np.ceil(clipRect.top()) - maxY = np.floor(clipRect.bottom()) - if pos.x() < minX or pos.x() > maxX or\ - pos.y() < minY or pos.y() > maxY: - return - painter.drawPoint(pos) + else: + painter.fillRect(rect, brush) + + +class QwtPainterClass(object): + """A collection of `QPainter` workarounds""" - def drawPoints(self, painter, points, pointCount): - deviceClipping, clipRect = qwtIsClippingNeeded(painter) - if isinstance(points[0], QPointF): - if deviceClipping: - clippedPolygon = QPolygonF(pointCount) - clippedData = clippedPolygon.data() - numClippedPoints = 0 - for point in points: - if clipRect.contains(point): - clippedData[numClippedPoints] = point - numClippedPoints += 1 - painter.drawPoints(clippedData, numClippedPoints) - else: - painter.drawPoints(points, pointCount) - else: - if deviceClipping: - minX = np.ceil(clipRect.left()) - maxX = np.floor(clipRect.right()) - minY = np.ceil(clipRect.top()) - maxY = np.floor(clipRect.bottom()) - r = QRect(minX, minY, maxX-minX, maxY-minY) - clippedPolygon = QPolygon(pointCount) - clippedData = clippedPolygon.data() - numClippedPoints = 0 - for point in points: - if r.contains(point): - clippedData[numClippedPoints] = point - numClippedPoints += 1 - painter.drawPoints(clippedData, numClippedPoints) - else: - painter.drawPoints(points, pointCount) - - def drawImage(self, painter, rect, image): - alignedRect = rect.toAlignedRect() - if alignedRect != rect: - clipRect = rect.adjusted(0., 0., -1., -1.) - painter.save() - painter.setClipRect(clipRect, Qt.IntersectClip) - painter.drawImage(alignedRect, image) - painter.restore() - else: - painter.drawImage(alignedRect, image) - - def drawPixmap(self, painter, rect, pixmap): - alignedRect = rect.toAlignedRect() - if alignedRect != rect: - clipRect = rect.adjusted(0., 0., -1., -1.) - painter.save() - painter.setClipRect(clipRect, Qt.IntersectClip) - painter.drawPixmap(alignedRect, pixmap) - painter.restore() - else: - painter.drawPixmap(alignedRect, pixmap) - def drawFocusRect(self, *args): if len(args) == 2: painter, widget = args @@ -373,47 +75,45 @@ def drawFocusRect(self, *args): opt.initFrom(widget) opt.rect = rect opt.state |= QStyle.State_HasFocus - widget.style().drawPrimitive(QStyle.PE_FrameFocusRect, - opt, painter, widget) - else: - raise TypeError("QwtPainter.drawFocusRect() takes 2 or 3 argument"\ - "(s) (%s given)" % len(args)) - - def drawRoundFrame(self, painter, rect, palette, lineWidth, frameStyle): - Plain, Sunken, Raised = list(range(3)) - style = Plain - if (frameStyle & QFrame.Sunken) == QFrame.Sunken: - style = Sunken - elif (frameStyle & QFrame.Raised) == QFrame.Raised: - style = Raised - lw2 = .5*lineWidth - r = rect.adjusted(lw2, lw2, -lw2, -lw2) - if style != Plain: - c1 = palette.color(QPalette.Light) - c2 = palette.color(QPalette.Dark) - if style == Sunken: - c1, c2 = c2, c1 - gradient = QLinearGradient(r.topLeft(), r.bottomRight()) - gradient.setColorAt(0., c1) - gradient.setColorAt(1., c2) - brush = QBrush(gradient) + palette = widget.palette() + opt.backgroundColor = palette.color(widget.backgroundRole()) + widget.style().drawPrimitive(QStyle.PE_FrameFocusRect, opt, painter, widget) else: - brush = palette.brush(QPalette.WindowText) - painter.save() - painter.setPen(QPen(brush, lineWidth)) - painter.drawEllipse(r) - painter.restore() - - def drawFrame(self, painter, rect, palette, foregroundRole, - frameWidth, midLineWidth, frameStyle): + raise TypeError( + "QwtPainter.drawFocusRect() takes 2 or 3 argument" + "(s) (%s given)" % len(args) + ) + + def drawFrame( + self, + painter, + rect, + palette, + foregroundRole, + frameWidth, + midLineWidth, + frameStyle, + ): + """ + Draw a rectangular frame + + :param QPainter painter: Painter + :param QRectF rect: Frame rectangle + :param QPalette palette: Palette + :param QPalette.ColorRole foregroundRole: Palette + :param int frameWidth: Frame width + :param int midLineWidth: Used for `QFrame.Box` + :param int frameStyle: bitwise OR´ed value of `QFrame.Shape` and `QFrame.Shadow` + """ if frameWidth <= 0 or rect.isEmpty(): return shadow = frameStyle & QFrame.Shadow_Mask painter.save() if shadow == QFrame.Plain: - outerRect = rect.adjusted(0., 0., -1., -1.) + outerRect = rect.adjusted(0.0, 0.0, -1.0, -1.0) innerRect = outerRect.adjusted( - frameWidth, frameWidth, -frameWidth, -frameWidth) + frameWidth, frameWidth, -frameWidth, -frameWidth + ) path = QPainterPath() path.addRect(outerRect) path.addRect(innerRect) @@ -423,13 +123,16 @@ def drawFrame(self, painter, rect, palette, foregroundRole, else: shape = frameStyle & QFrame.Shape_Mask if shape == QFrame.Box: - outerRect = rect.adjusted(0., 0., -1., -1.) + outerRect = rect.adjusted(0.0, 0.0, -1.0, -1.0) midRect1 = outerRect.adjusted( - frameWidth, frameWidth, -frameWidth, -frameWidth) + frameWidth, frameWidth, -frameWidth, -frameWidth + ) midRect2 = midRect1.adjusted( - midLineWidth, midLineWidth, -midLineWidth, -midLineWidth) + midLineWidth, midLineWidth, -midLineWidth, -midLineWidth + ) innerRect = midRect2.adjusted( - frameWidth, frameWidth, -frameWidth, -frameWidth) + frameWidth, frameWidth, -frameWidth, -frameWidth + ) path1 = QPainterPath() path1.moveTo(outerRect.bottomLeft()) path1.lineTo(outerRect.topLeft()) @@ -475,9 +178,13 @@ def drawFrame(self, painter, rect, palette, foregroundRole, painter.setBrush(palette.mid()) painter.drawPath(path5) else: - outerRect = rect.adjusted(0., 0., -1., -1.) - innerRect = outerRect.adjusted(frameWidth-1., frameWidth-1., - -(frameWidth-1.), -(frameWidth-1.)) + outerRect = rect.adjusted(0.0, 0.0, -1.0, -1.0) + innerRect = outerRect.adjusted( + frameWidth - 1.0, + frameWidth - 1.0, + -(frameWidth - 1.0), + -(frameWidth - 1.0), + ) path1 = QPainterPath() path1.moveTo(outerRect.bottomLeft()) path1.lineTo(outerRect.topLeft()) @@ -502,13 +209,25 @@ def drawFrame(self, painter, rect, palette, foregroundRole, painter.setBrush(brush2) painter.drawPath(path2) painter.restore() - - def drawRoundedFrame(self, painter, rect, xRadius, yRadius, - palette, lineWidth, frameStyle): + + def drawRoundedFrame( + self, painter, rect, xRadius, yRadius, palette, lineWidth, frameStyle + ): + """ + Draw a rectangular frame with rounded borders + + :param QPainter painter: Painter + :param QRectF rect: Frame rectangle + :param float xRadius: x-radius of the ellipses defining the corners + :param float yRadius: y-radius of the ellipses defining the corners + :param QPalette palette: `QPalette.WindowText` is used for plain borders, `QPalette.Dark` and `QPalette.Light` for raised or sunken borders + :param int lineWidth: Line width + :param int frameStyle: bitwise OR´ed value of `QFrame.Shape` and `QFrame.Shadow` + """ painter.save() painter.setRenderHint(QPainter.Antialiasing, True) painter.setBrush(Qt.NoBrush) - lw2 = lineWidth*.5 + lw2 = lineWidth * 0.5 r = rect.adjusted(lw2, lw2, -lw2, -lw2) path = QPainterPath() path.addRoundedRect(r, xRadius, yRadius) @@ -521,23 +240,28 @@ def drawRoundedFrame(self, painter, rect, xRadius, yRadius, if style != Plain and path.elementCount() == 17: pathList = [QPainterPath() for _i in range(8)] for i in range(4): - j = i*4+1 - pathList[2*i].moveTo(path.elementAt(j-1).x, - path.elementAt(j-1).y) - pathList[2*i].cubicTo( - path.elementAt(j+0).x, path.elementAt(j+0).y, - path.elementAt(j+1).x, path.elementAt(j+1).y, - path.elementAt(j+2).x, path.elementAt(j+2).y) - pathList[2*i+1].moveTo(path.elementAt(j+2).x, - path.elementAt(j+2).y) - pathList[2*i+1].lineTo(path.elementAt(j+3).x, - path.elementAt(j+3).y) + j = i * 4 + 1 + pathList[2 * i].moveTo(path.elementAt(j - 1).x, path.elementAt(j - 1).y) + pathList[2 * i].cubicTo( + path.elementAt(j + 0).x, + path.elementAt(j + 0).y, + path.elementAt(j + 1).x, + path.elementAt(j + 1).y, + path.elementAt(j + 2).x, + path.elementAt(j + 2).y, + ) + pathList[2 * i + 1].moveTo( + path.elementAt(j + 2).x, path.elementAt(j + 2).y + ) + pathList[2 * i + 1].lineTo( + path.elementAt(j + 3).x, path.elementAt(j + 3).y + ) c1 = QColor(palette.color(QPalette.Dark)) c2 = QColor(palette.color(QPalette.Light)) if style == Raised: c1, c2 = c2, c1 - for i in range(5): - r = pathList[2*i].controlPointRect() + for i in range(4): + r = pathList[2 * i].controlPointRect() arcPen = QPen() arcPen.setCapStyle(Qt.FlatCap) arcPen.setWidth(lineWidth) @@ -551,8 +275,8 @@ def drawRoundedFrame(self, painter, rect, xRadius, yRadius, gradient = QLinearGradient() gradient.setStart(r.topLeft()) gradient.setFinalStop(r.bottomRight()) - gradient.setColorAt(0., c1) - gradient.setColorAt(1., c2) + gradient.setColorAt(0.0, c1) + gradient.setColorAt(1.0, c2) arcPen.setBrush(gradient) linePen.setColor(c2) elif i == 2: @@ -562,22 +286,31 @@ def drawRoundedFrame(self, painter, rect, xRadius, yRadius, gradient = QLinearGradient() gradient.setStart(r.bottomRight()) gradient.setFinalStop(r.topLeft()) - gradient.setColorAt(0., c2) - gradient.setColorAt(1., c1) + gradient.setColorAt(0.0, c2) + gradient.setColorAt(1.0, c1) arcPen.setBrush(gradient) linePen.setColor(c1) painter.setPen(arcPen) - painter.drawPath(pathList[2*i]) + painter.drawPath(pathList[2 * i]) painter.setPen(linePen) - painter.drawPath(pathList[2*i+1]) + painter.drawPath(pathList[2 * i + 1]) else: pen = QPen(palette.color(QPalette.WindowText), lineWidth) painter.setPen(pen) painter.drawPath(path) painter.restore() - - def drawColorBar(self, painter, colorMap, interval, scaleMap, - orientation, rect): + + def drawColorBar(self, painter, colorMap, interval, scaleMap, orientation, rect): + """ + Draw a color bar into a rectangle + + :param QPainter painter: Painter + :param qwt.color_map.QwtColorMap colorMap: Color map + :param qwt.interval.QwtInterval interval: Value range + :param qwt.scalemap.QwtScaleMap scaleMap: Scale map + :param Qt.Orientation orientation: Orientation + :param QRectF rect: Target rectangle + """ colorTable = [] if colorMap.format() == QwtColorMap.Indexed: colorTable = colorMap.colorTable(interval) @@ -588,31 +321,46 @@ def drawColorBar(self, painter, colorMap, interval, scaleMap, pmPainter = QPainter(pixmap) pmPainter.translate(-devRect.x(), -devRect.y()) if orientation == Qt.Horizontal: - sMap = scaleMap + sMap = QwtScaleMap(scaleMap) sMap.setPaintInterval(rect.left(), rect.right()) - for x in range(devRect.left(), devRect.right()+1): + for x in range(devRect.left(), devRect.right() + 1): value = sMap.invTransform(x) if colorMap.format() == QwtColorMap.RGB: c.setRgba(colorMap.rgb(interval, value)) else: c = colorTable[colorMap.colorIndex(interval, value)] pmPainter.setPen(c) - pmPainter.drawLine(x, devRect.top(), devRect.bottom()) + pmPainter.drawLine(QLineF(x, devRect.top(), x, devRect.bottom())) else: - sMap = scaleMap + sMap = QwtScaleMap(scaleMap) sMap.setPaintInterval(rect.bottom(), rect.top()) - for y in range(devRect.top(), devRect.bottom()+1): + for y in range(devRect.top(), devRect.bottom() + 1): value = sMap.invTransform(y) if colorMap.format() == QwtColorMap.RGB: c.setRgba(colorMap.rgb(interval, value)) else: c = colorTable[colorMap.colorIndex(interval, value)] pmPainter.setPen(c) - pmPainter.drawLine(devRect.left(), y, devRect.right(), y) + pmPainter.drawLine(QLineF(devRect.left(), y, devRect.right(), y)) pmPainter.end() - self.drawPixmap(painter, rect, pixmap) - + painter.drawPixmap(devRect, pixmap) + def fillPixmap(self, widget, pixmap, offset=None): + """ + Fill a pixmap with the content of a widget + + In Qt >= 5.0 `QPixmap.fill()` is a nop, in Qt 4.x it is buggy + for backgrounds with gradients. Thus `fillPixmap()` offers + an alternative implementation. + + :param QWidget widget: Widget + :param QPixmap pixmap: Pixmap to be filled + :param QPoint offset: Offset + + .. seealso:: + + :py:meth:`QPixmap.fill()` + """ if offset is None: offset = QPoint() rect = QRect(offset, pixmap.size()) @@ -625,57 +373,47 @@ def fillPixmap(self, widget, pixmap, offset=None): if widget.autoFillBackground(): qwtFillRect(widget, painter, rect, autoFillBrush) if widget.testAttribute(Qt.WA_StyledBackground): - painter.setClipRegion(rect) + painter.setClipRegion(QRegion(rect)) opt = QStyleOption() opt.initFrom(widget) - widget.style().drawPrimitive(QStyle.PE_Widget, opt, - painter, widget) - + widget.style().drawPrimitive(QStyle.PE_Widget, opt, painter, widget) + def drawBackground(self, painter, rect, widget): + """ + Fill rect with the background of a widget + + :param QPainter painter: Painter + :param QRectF rect: Rectangle to be filled + :param QWidget widget: Widget + + .. seealso:: + + :py:data:`QStyle.PE_Widget`, :py:meth:`QWidget.backgroundRole()` + """ if widget.testAttribute(Qt.WA_StyledBackground): opt = QStyleOption() opt.initFrom(widget) - opt.rect = rect.toAlignedRect() - widget.style().drawPrimitive(QStyle.PE_Widget, opt, - painter, widget) + opt.rect = rect.toRect() + widget.style().drawPrimitive(QStyle.PE_Widget, opt, painter, widget) else: brush = widget.palette().brush(widget.backgroundRole()) painter.fillRect(rect, brush) - + def backingStore(self, widget, size): - if QT_VERSION >= 0x050000: - pixelRatio = 1. - if widget and widget.windowHandle(): - pixelRatio = widget.windowHandle().devicePixelRatio() - else: - from qwt.qt.QtGui import qApp - if qApp is not None: - try: - pixelRatio = qApp.devicePixelRatio() - except RuntimeError: - pass - pm = QPixmap(size*pixelRatio) - pm.setDevicePixelRatio(pixelRatio) + """ + :param QWidget widget: Widget, for which the backinstore is intended + :param QSize size: Size of the pixmap + :return: A pixmap that can be used as backing store + """ + pixelRatio = 1.0 + if widget and widget.windowHandle(): + pixelRatio = widget.windowHandle().devicePixelRatio() else: - pm = QPixmap(size) - if QT_VERSION < 0x050000 and widget and isX11GraphicsSystem(): - if pm.x11Info().screen() != widget.x11Info().screen(): - pm.x11SetScreen(widget.x11Info().screen()) + qapp = QApplication.instance() + pixelRatio = qapp.devicePixelRatio() + pm = QPixmap(size * pixelRatio) + pm.setDevicePixelRatio(pixelRatio) return pm -QwtPainter = QwtPainterClass() - -def qwtFillRect(widget, painter, rect, brush): - if brush.style() == Qt.TexturePattern: - painter.save() - painter.setClipRect(rect) - painter.drawTiledPixmap(rect, brush.texture(), rect.topLeft()) - painter.restore() - elif brush.gradient(): - painter.save() - painter.setClipRect(rect) - painter.fillRect(0, 0, widget.width(), widget.height(), brush) - painter.restore() - else: - painter.fillRect(rect, brush) +QwtPainter = QwtPainterClass() diff --git a/qwt/painter_command.py b/qwt/painter_command.py index 987da97..d923be9 100644 --- a/qwt/painter_command.py +++ b/qwt/painter_command.py @@ -5,7 +5,51 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.qt.QtGui import QPainterPath, QPaintEngine +""" +QwtPainterCommand +----------------- + +.. autoclass:: QwtPainterCommand + :members: +""" + +import copy + +from qtpy.QtGui import QPaintEngine, QPainterPath + + +def _flag_int(flag): + """Return the integer value of a Qt enum/flag (PyQt5 and PyQt6). + + PyQt5 exposes Qt enums as plain ints (``int(flag)`` works). PyQt6 wraps + them as ``enum.Flag`` instances which are not ``int`` subclasses, so + ``int(flag)`` raises -- the value must be read from ``flag.value``. + """ + try: + return flag.value + except AttributeError: + return int(flag) + + +# Cache QPaintEngine.DirtyXxx flags as plain Python ints once at import time. +# On PyQt6, Qt enums are full ``enum.Flag`` instances and every ``flags & +# Member`` test goes through Python's ``enum.__and__`` machinery (~6 us each). +# In ``QwtPainterCommand.__init__`` below, the State branch performs twelve +# successive flag tests per painter command -- on PyQt6 alone this accounted +# for ~20 ms of the residual perf gap on the load test. Casting once to int +# and bitwise-testing against int constants brings each test back to ~50 ns. +_DIRTY_PEN = _flag_int(QPaintEngine.DirtyPen) +_DIRTY_BRUSH = _flag_int(QPaintEngine.DirtyBrush) +_DIRTY_BRUSH_ORIGIN = _flag_int(QPaintEngine.DirtyBrushOrigin) +_DIRTY_FONT = _flag_int(QPaintEngine.DirtyFont) +_DIRTY_BACKGROUND = _flag_int(QPaintEngine.DirtyBackground) +_DIRTY_TRANSFORM = _flag_int(QPaintEngine.DirtyTransform) +_DIRTY_CLIP_ENABLED = _flag_int(QPaintEngine.DirtyClipEnabled) +_DIRTY_CLIP_REGION = _flag_int(QPaintEngine.DirtyClipRegion) +_DIRTY_CLIP_PATH = _flag_int(QPaintEngine.DirtyClipPath) +_DIRTY_HINTS = _flag_int(QPaintEngine.DirtyHints) +_DIRTY_COMPOSITION_MODE = _flag_int(QPaintEngine.DirtyCompositionMode) +_DIRTY_OPACITY = _flag_int(QPaintEngine.DirtyOpacity) class PixmapData(object): @@ -14,6 +58,7 @@ def __init__(self): self.pixmap = None self.subRect = None + class ImageData(object): def __init__(self): self.rect = None @@ -21,6 +66,7 @@ def __init__(self): self.subRect = None self.flags = None + class StateData(object): def __init__(self): self.flags = None @@ -40,17 +86,67 @@ def __init__(self): self.compositionMode = None self.opacity = None + class QwtPainterCommand(object): - + """ + `QwtPainterCommand` represents the attributes of a paint operation + how it is used between `QPainter` and `QPaintDevice` + + It is used by :py:class:`qwt.graphic.QwtGraphic` to record and replay + paint operations + + .. seealso:: + + :py:meth:`qwt.graphic.QwtGraphic.commands()` + + + .. py:class:: QwtPainterCommand() + + Construct an invalid command + + .. py:class:: QwtPainterCommand(path) + :noindex: + + Copy constructor + + :param QPainterPath path: Source + + .. py:class:: QwtPainterCommand(rect, pixmap, subRect) + :noindex: + + Constructor for Pixmap paint operation + + :param QRectF rect: Target rectangle + :param QPixmap pixmap: Pixmap + :param QRectF subRect: Rectangle inside the pixmap + + .. py:class:: QwtPainterCommand(rect, image, subRect, flags) + :noindex: + + Constructor for Image paint operation + + :param QRectF rect: Target rectangle + :param QImage image: Image + :param QRectF subRect: Rectangle inside the image + :param Qt.ImageConversionFlags flags: Conversion flags + + .. py:class:: QwtPainterCommand(state) + :noindex: + + Constructor for State paint operation + + :param QPaintEngineState state: Paint engine state + """ + # enum Type Invalid = -1 Path, Pixmap, Image, State = list(range(4)) - + def __init__(self, *args): if len(args) == 0: self.__type = self.Invalid elif len(args) == 1: - arg, = args + (arg,) = args if isinstance(arg, QPainterPath): path = arg self.__type = self.Path @@ -63,32 +159,35 @@ def __init__(self, *args): self.__type = self.State self.__stateData = StateData() self.__stateData.flags = state.state() - if self.__stateData.flags & QPaintEngine.DirtyPen: + # Cast to int once: subsequent bitwise tests are done against + # the cached _DIRTY_* int constants (see top of module). + flags = _flag_int(self.__stateData.flags) + if flags & _DIRTY_PEN: self.__stateData.pen = state.pen() - if self.__stateData.flags & QPaintEngine.DirtyBrush: + if flags & _DIRTY_BRUSH: self.__stateData.brush = state.brush() - if self.__stateData.flags & QPaintEngine.DirtyBrushOrigin: + if flags & _DIRTY_BRUSH_ORIGIN: self.__stateData.brushOrigin = state.brushOrigin() - if self.__stateData.flags & QPaintEngine.DirtyFont: + if flags & _DIRTY_FONT: self.__stateData.font = state.font() - if self.__stateData.flags & QPaintEngine.DirtyBackground: + if flags & _DIRTY_BACKGROUND: self.__stateData.backgroundMode = state.backgroundMode() self.__stateData.backgroundBrush = state.backgroundBrush() - if self.__stateData.flags & QPaintEngine.DirtyTransform: + if flags & _DIRTY_TRANSFORM: self.__stateData.transform = state.transform() - if self.__stateData.flags & QPaintEngine.DirtyClipEnabled: + if flags & _DIRTY_CLIP_ENABLED: self.__stateData.isClipEnabled = state.isClipEnabled() - if self.__stateData.flags & QPaintEngine.DirtyClipRegion: + if flags & _DIRTY_CLIP_REGION: self.__stateData.clipRegion = state.clipRegion() self.__stateData.clipOperation = state.clipOperation() - if self.__stateData.flags & QPaintEngine.DirtyClipPath: + if flags & _DIRTY_CLIP_PATH: self.__stateData.clipPath = state.clipPath() self.__stateData.clipOperation = state.clipOperation() - if self.__stateData.flags & QPaintEngine.DirtyHints: + if flags & _DIRTY_HINTS: self.__stateData.renderHints = state.renderHints() - if self.__stateData.flags & QPaintEngine.DirtyCompositionMode: + if flags & _DIRTY_COMPOSITION_MODE: self.__stateData.compositionMode = state.compositionMode() - if self.__stateData.flags & QPaintEngine.DirtyOpacity: + if flags & _DIRTY_OPACITY: self.__stateData.opacity = state.opacity() elif len(args) == 3: rect, pixmap, subRect = args @@ -106,34 +205,36 @@ def __init__(self, *args): self.__imageData.subRect = subRect self.__imageData.flags = flags else: - raise TypeError("%s() takes 0, 1, 3 or 4 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s() takes 0, 1, 3 or 4 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + def copy(self, other): self.__type = other.__type if other.__type == self.Path: self.__path = QPainterPath(other.__path) elif other.__type == self.Pixmap: - self.__pixmapData = PixmapData(other.__pixmapData) + self.__pixmapData = copy.deepcopy(other.__pixmapData) elif other.__type == self.Image: - self.__imageData = ImageData(other.__imageData) + self.__imageData = copy.deepcopy(other.__imageData) elif other.__type == self.State: - self.__stateData == StateData(other.__stateData) - + self.__stateData == copy.deepcopy(other.__stateData) + def reset(self): self.__type = self.Invalid def type(self): return self.__type - + def path(self): return self.__path - + def pixmapData(self): return self.__pixmapData - + def imageData(self): return self.__imageData - + def stateData(self): return self.__stateData diff --git a/qwt/pixel_matrix.py b/qwt/pixel_matrix.py deleted file mode 100644 index 9eae099..0000000 --- a/qwt/pixel_matrix.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.qt.QtCore import QBitArray - - -class QwtPixelMatrix(QBitArray): - def __init__(self, rect): - QBitArray.__init__(self, max([rect.width()*rect.height(), 0])) - self.__rect = rect - - def setRect(self, rect): - if rect != self.__rect: - self.__rect = rect - sz = max([rect.width()*rect.height(), 0]) - self.resize(sz) - self.fill(False) - - def rect(self): - return self.__rect - - def testPixel(self, x, y): - idx = self.index(x, y) - if idx >= 0: - return self.testBit(idx) - else: - return True - - def testAndSetPixel(self, x, y, on): - idx = self.index(x, y) - if idx < 0: - return True - onBefore = self.testBit(idx) - self.setBit(idx, on) - return onBefore - - def index(self, x, y): - dx = x - self.__rect.x() - if dx < 0 or dx >= self.__rect.width(): - return -1 - dy = y - self.__rect.y() - if dy < 0 or dy >= self.__rect.height(): - return -1 - return dy*self.__rect.width()+dx - \ No newline at end of file diff --git a/qwt/plot.py b/qwt/plot.py index 1d1c0e6..ced78a4 100644 --- a/qwt/plot.py +++ b/qwt/plot.py @@ -5,29 +5,38 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.qt.QtGui import (QWidget, QFont, QSizePolicy, QFrame, QApplication, - QRegion, QPainter, QPalette) -from qwt.qt.QtCore import Qt, Signal, QEvent, QSize, QRectF +""" +QwtPlot +------- -from qwt.text import QwtText, QwtTextLabel -from qwt.scale_widget import QwtScaleWidget -from qwt.scale_draw import QwtScaleDraw -from qwt.scale_engine import QwtLinearScaleEngine -from qwt.plot_canvas import QwtPlotCanvas -from qwt.scale_div import QwtScaleDiv -from qwt.scale_map import QwtScaleMap -from qwt.graphic import QwtGraphic -from qwt.legend_data import QwtLegendData -from qwt.interval import QwtInterval +.. autoclass:: QwtPlot + :members: + +QwtPlotItem +----------- + +.. autoclass:: QwtPlotItem + :members: +""" + +import math import numpy as np +from qtpy.QtCore import QEvent, QObject, QRectF, QSize, Qt, Signal +from qtpy.QtGui import QBrush, QColor, QFont, QPainter, QPalette +from qtpy.QtWidgets import QApplication, QFrame, QSizePolicy, QWidget +from qwt.graphic import QwtGraphic +from qwt.interval import QwtInterval +from qwt.legend import QwtLegendData +from qwt.plot_canvas import QwtPlotCanvas +from qwt.scale_div import QwtScaleDiv +from qwt.scale_draw import QwtScaleDraw +from qwt.scale_engine import QwtLinearScaleEngine +from qwt.scale_map import QwtScaleMap +from qwt.scale_widget import QwtScaleWidget +from qwt.text import QwtText, QwtTextLabel -def qwtEnableLegendItems(plot, on): - if on: - plot.SIG_LEGEND_DATA_CHANGED.connect(plot.updateLegendItems) - else: - plot.SIG_LEGEND_DATA_CHANGED.disconnect(plot.updateLegendItems) def qwtSetTabOrder(first, second, with_children): tab_chain = [first, second] @@ -39,9 +48,9 @@ def qwtSetTabOrder(first, second, with_children): children.remove(w) tab_chain += [w] w = w.nextInFocusChain() - for idx in range(len(tab_chain)-1): + for idx in range(len(tab_chain) - 1): w_from = tab_chain[idx] - w_to = tab_chain[idx+1] + w_to = tab_chain[idx + 1] policy1, policy2 = w_from.focusPolicy(), w_to.focusPolicy() proxy1, proxy2 = w_from.focusProxy(), w_to.focusProxy() for w in (w_from, w_to): @@ -60,56 +69,24 @@ def sortItems(self): def insertItem(self, obj): self.append(obj) self.sortItems() - + def removeItem(self, obj): self.remove(obj) self.sortItems() -class QwtPlotDict_PrivateData(object): - def __init__(self): - self.itemList = ItemList() - self.autoDelete = True - - -class QwtPlotDict(object): +class QwtPlot_PrivateData(QObject): def __init__(self): - self.__data = QwtPlotDict_PrivateData() - - def setAutoDelete(self, autoDelete): - self.__data.autoDelete = autoDelete - - def autoDelete(self): - return self.__data.autoDelete - - def insertItem(self, item): - self.__data.itemList.insertItem(item) - - def removeItem(self, item): - self.__data.itemList.removeItem(item) - - def detachItems(self, rtti, autoDelete): - for item in self.__data.itemList[:]: - if rtti == QwtPlotItem.Rtti_PlotItem and item.rtti() == rtti: - item.attach(None) - if self.autoDelete: - self.__data.itemList.remove(item) - - def itemList(self, rtti=None): - if rtti is None or rtti == QwtPlotItem.Rtti_PlotItem: - return self.__data.itemList - return [item for item in self.__data.itemList if item.rtti() == rtti] + QObject.__init__(self) - -class QwtPlot_PrivateData(QwtPlotDict_PrivateData): - def __init__(self): - super(QwtPlot_PrivateData, self).__init__() + self.itemList = ItemList() self.titleLabel = None self.footerLabel = None self.canvas = None self.legend = None self.layout = None self.autoReplot = None + self.flatStyle = None class AxisData(object): @@ -122,251 +99,639 @@ def __init__(self): self.maxMajor = None self.maxMinor = None self.isValid = None - self.scaleDiv = None # QwtScaleDiv - self.scaleEngine = None # QwtScaleEngine - self.scaleWidget = None # QwtScaleWidget + self.scaleDiv = None # QwtScaleDiv + self.scaleEngine = None # QwtScaleEngine + self.scaleWidget = None # QwtScaleWidget + self.margin = None # Margin (float) in % + + +class QwtPlot(QFrame): + """ + A 2-D plotting widget + + QwtPlot is a widget for plotting two-dimensional graphs. + An unlimited number of plot items can be displayed on its canvas. + Plot items might be curves (:py:class:`qwt.plot_curve.QwtPlotCurve`), + markers (:py:class:`qwt.plot_marker.QwtPlotMarker`), + the grid (:py:class:`qwt.plot_grid.QwtPlotGrid`), or anything else + derived from :py:class:`QwtPlotItem`. + + A plot can have up to four axes, with each plot item attached to an x- and + a y axis. The scales at the axes can be explicitly set (`QwtScaleDiv`), or + are calculated from the plot items, using algorithms (`QwtScaleEngine`) + which can be configured separately for each axis. + + The following example is a good starting point to see how to set up a + plot widget:: + + from qtpy import QtWidgets as QW + import qwt + import numpy as np + + app = QW.QApplication([]) + x = np.linspace(-10, 10, 500) + plot = qwt.QwtPlot("Trigonometric functions") + plot.insertLegend(qwt.QwtLegend(), qwt.QwtPlot.BottomLegend) + qwt.QwtPlotCurve.make(x, np.cos(x), "Cosine", plot, linecolor="red", antialiased=True) + qwt.QwtPlotCurve.make(x, np.sin(x), "Sine", plot, linecolor="blue", antialiased=True) + plot.resize(600, 300) + plot.show() + .. image:: /_static/QwtPlot_example.png -class QwtPlot(QFrame, QwtPlotDict): - SIG_ITEM_ATTACHED = Signal("PyQt_PyObject", bool) - SIG_LEGEND_DATA_CHANGED = Signal("PyQt_PyObject", "PyQt_PyObject") + .. py:class:: QwtPlot([title=""], [parent=None]) + + :param str title: Title text + :param QWidget parent: Parent widget + + .. py:data:: itemAttached + + A signal indicating, that an item has been attached/detached + + :param plotItem: Plot item + :param on: Attached/Detached + + .. py:data:: legendDataChanged + + A signal with the attributes how to update + the legend entries for a plot item. + + :param itemInfo: Info about a plot item, build from itemToInfo() + :param data: Attributes of the entries (usually <= 1) for the plot item. + + """ + + itemAttached = Signal(object, bool) + legendDataChanged = Signal(object, object) # enum Axis - yLeft, yRight, xBottom, xTop, axisCnt = list(range(5)) - + AXES = yLeft, yRight, xBottom, xTop = list(range(4)) + axisCnt = len(AXES) # Not necessary but ensure compatibility with PyQwt + # enum LegendPosition LeftLegend, RightLegend, BottomLegend, TopLegend = list(range(4)) - + def __init__(self, *args): if len(args) == 0: title, parent = "", None elif len(args) == 1: if isinstance(args[0], QWidget) or args[0] is None: title = "" - parent, = args + (parent,) = args else: - title, = args + (title,) = args parent = None elif len(args) == 2: title, parent = args else: - raise TypeError("%s() takes 0, 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - QwtPlotDict.__init__(self) + raise TypeError( + "%s() takes 0, 1 or 2 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) QFrame.__init__(self, parent) - + self.__layout_state = None - + self.__data = QwtPlot_PrivateData() from qwt.plot_layout import QwtPlotLayout + self.__data.layout = QwtPlotLayout() self.__data.autoReplot = False - - self.setAutoReplot(True) -# self.setPlotLayout(self.__data.layout) - + + self.setAutoReplot(False) + self.setPlotLayout(self.__data.layout) + # title self.__data.titleLabel = QwtTextLabel(self) self.__data.titleLabel.setObjectName("QwtPlotTitle") - self.__data.titleLabel.setFont(QFont(self.fontInfo().family(), 14, - QFont.Bold)) text = QwtText(title) - text.setRenderFlags(Qt.AlignCenter|Qt.TextWordWrap) + text.setRenderFlags(Qt.AlignCenter | Qt.TextWordWrap) self.__data.titleLabel.setText(text) - + # footer self.__data.footerLabel = QwtTextLabel(self) self.__data.footerLabel.setObjectName("QwtPlotFooter") footer = QwtText() - footer.setRenderFlags(Qt.AlignCenter|Qt.TextWordWrap) + footer.setRenderFlags(Qt.AlignCenter | Qt.TextWordWrap) self.__data.footerLabel.setText(footer) - + # legend self.__data.legend = None - + # axis self.__axisData = [] self.initAxesData() - + # canvas self.__data.canvas = QwtPlotCanvas(self) self.__data.canvas.setObjectName("QwtPlotCanvas") self.__data.canvas.installEventFilter(self) - - self.setSizePolicy(QSizePolicy.MinimumExpanding, - QSizePolicy.MinimumExpanding) - - self.resize(200, 200) - - focusChain = [self, self.__data.titleLabel, self.axisWidget(self.xTop), - self.axisWidget(self.yLeft), self.__data.canvas, - self.axisWidget(self.yRight), - self.axisWidget(self.xBottom), self.__data.footerLabel] - - for idx in range(len(focusChain)-1): - qwtSetTabOrder(focusChain[idx], focusChain[idx+1], False) - - qwtEnableLegendItems(self, True) - - def __del__(self): - #XXX Is is really necessary in Python? (pure transcription of C++) - self.setAutoReplot(False) - self.detachItems(QwtPlotItem.Rtti_PlotItem, self.autoDelete()) - self.__data.layout = None - self.deleteAxesData() - self.__data = None - + + # plot style + self.setFlatStyle(True) + + self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding) + + focusChain = [ + self, + self.__data.titleLabel, + self.axisWidget(self.xTop), + self.axisWidget(self.yLeft), + self.__data.canvas, + self.axisWidget(self.yRight), + self.axisWidget(self.xBottom), + self.__data.footerLabel, + ] + + for idx in range(len(focusChain) - 1): + qwtSetTabOrder(focusChain[idx], focusChain[idx + 1], False) + + self.legendDataChanged.connect(self.updateLegendItems) + + def insertItem(self, item): + """ + Insert a plot item + + :param qwt.plot.QwtPlotItem item: PlotItem + + .. seealso:: + + :py:meth:`removeItem()` + + .. note:: + + This was a member of QwtPlotDict in older versions. + """ + self.__data.itemList.insertItem(item) + + def removeItem(self, item): + """ + Remove a plot item + + :param qwt.plot.QwtPlotItem item: PlotItem + + .. seealso:: + + :py:meth:`insertItem()` + + .. note:: + + This was a member of QwtPlotDict in older versions. + """ + self.__data.itemList.removeItem(item) + + def detachItems(self, rtti=None): + """ + Detach items from the dictionary + + :param rtti: In case of `QwtPlotItem.Rtti_PlotItem` or None (default) detach all items otherwise only those items of the type rtti. + :type rtti: int or None + + .. note:: + + This was a member of QwtPlotDict in older versions. + """ + for item in self.__data.itemList[:]: + if rtti in (None, QwtPlotItem.Rtti_PlotItem) or item.rtti() == rtti: + item.attach(None) + + def itemList(self, rtti=None): + """ + A list of attached plot items. + + Use caution when iterating these lists, as removing/detaching an + item will invalidate the iterator. Instead you can place pointers + to objects to be removed in a removal list, and traverse that list + later. + + :param int rtti: In case of `QwtPlotItem.Rtti_PlotItem` detach all items otherwise only those items of the type rtti. + :return: List of all attached plot items of a specific type. If rtti is None, return a list of all attached plot items. + + .. note:: + + This was a member of QwtPlotDict in older versions. + """ + if rtti is None or rtti == QwtPlotItem.Rtti_PlotItem: + return self.__data.itemList + return [item for item in self.__data.itemList if item.rtti() == rtti] + + def setFlatStyle(self, state): + """ + Set or reset the flatStyle option + + If the flatStyle option is set, the plot will be + rendered without any margin (scales, canvas, layout). + + Enabling this option makes the plot look flat and compact. + + The flatStyle option is set to True by default. + + :param bool state: True or False. + + .. seealso:: + + :py:meth:`flatStyle()` + """ + + def make_font(family=None, size=None, delta_size=None, weight=None): + finfo = self.fontInfo() + family = finfo.family() if family is None else family + weight = -1 if weight is None else weight + size = size if delta_size is None else finfo.pointSize() + delta_size + return QFont(family, size, weight) + + if state: + # New PythonQwt-exclusive flat style + plot_title_font = make_font(size=12) + axis_title_font = make_font(size=11) + axis_label_font = make_font(size=10) + tick_lighter_factors = (150, 125, 100) + scale_margin = scale_spacing = 0 + canvas_frame_style = QFrame.NoFrame + plot_layout_canvas_margin = plot_layout_spacing = 0 + ticks_color = Qt.darkGray + labels_color = "#444444" + else: + # Old PyQwt / Qwt style + plot_title_font = make_font(size=14, weight=QFont.Bold) + axis_title_font = make_font(size=12, weight=QFont.Bold) + axis_label_font = make_font(size=10) + tick_lighter_factors = (100, 100, 100) + scale_margin = scale_spacing = 2 + canvas_frame_style = QFrame.Panel | QFrame.Sunken + plot_layout_canvas_margin = 4 + plot_layout_spacing = 5 + ticks_color = labels_color = Qt.black + self.canvas().setFrameStyle(canvas_frame_style) + self.plotLayout().setCanvasMargin(plot_layout_canvas_margin) + self.plotLayout().setSpacing(plot_layout_spacing) + palette = self.palette() + palette.setColor(QPalette.WindowText, QColor(ticks_color)) + palette.setColor(QPalette.Text, QColor(labels_color)) + self.setPalette(palette) + for axis_id in self.AXES: + scale_widget = self.axisWidget(axis_id) + scale_draw = self.axisScaleDraw(axis_id) + scale_widget.setFont(axis_label_font) + scale_widget.setMargin(scale_margin) + scale_widget.setSpacing(scale_spacing) + scale_title = scale_widget.title() + scale_title.setFont(axis_title_font) + scale_widget.setTitle(scale_title) + for tick_type, factor in enumerate(tick_lighter_factors): + scale_draw.setTickLighterFactor(tick_type, factor) + plot_title = self.title() + plot_title.setFont(plot_title_font) + self.setTitle(plot_title) + self.__data.flatStyle = state + + def flatStyle(self): + """ + :return: True if the flatStyle option is set. + + .. seealso:: + + :py:meth:`setFlatStyle()` + """ + return self.__data.flatStyle + def initAxesData(self): - self.__axisData = [AxisData() for axisId in range(self.axisCnt)] - - self.__axisData[self.yLeft].scaleWidget = \ - QwtScaleWidget(QwtScaleDraw.LeftScale, self) - self.__axisData[self.yRight].scaleWidget = \ - QwtScaleWidget(QwtScaleDraw.RightScale, self) - self.__axisData[self.xTop].scaleWidget = \ - QwtScaleWidget(QwtScaleDraw.TopScale, self) - self.__axisData[self.xBottom].scaleWidget = \ - QwtScaleWidget(QwtScaleDraw.BottomScale, self) - - self.__axisData[self.yLeft - ].scaleWidget.setObjectName("QwtPlotAxisYLeft") - self.__axisData[self.yRight - ].scaleWidget.setObjectName("QwtPlotAxisYRight") - self.__axisData[self.xTop - ].scaleWidget.setObjectName("QwtPlotAxisXTop") - self.__axisData[self.xBottom - ].scaleWidget.setObjectName("QwtPlotAxisXBottom") - - fscl = QFont(self.fontInfo().family(), 10) - fttl = QFont(self.fontInfo().family(), 12, QFont.Bold) - - for axisId in range(self.axisCnt): + """Initialize axes""" + self.__axisData = [AxisData() for axisId in self.AXES] + + self.__axisData[self.yLeft].scaleWidget = QwtScaleWidget( + QwtScaleDraw.LeftScale, self + ) + self.__axisData[self.yRight].scaleWidget = QwtScaleWidget( + QwtScaleDraw.RightScale, self + ) + self.__axisData[self.xTop].scaleWidget = QwtScaleWidget( + QwtScaleDraw.TopScale, self + ) + self.__axisData[self.xBottom].scaleWidget = QwtScaleWidget( + QwtScaleDraw.BottomScale, self + ) + + self.__axisData[self.yLeft].scaleWidget.setObjectName("QwtPlotAxisYLeft") + self.__axisData[self.yRight].scaleWidget.setObjectName("QwtPlotAxisYRight") + self.__axisData[self.xTop].scaleWidget.setObjectName("QwtPlotAxisXTop") + self.__axisData[self.xBottom].scaleWidget.setObjectName("QwtPlotAxisXBottom") + + for axisId in self.AXES: d = self.__axisData[axisId] d.scaleEngine = QwtLinearScaleEngine() d.scaleWidget.setTransformation(d.scaleEngine.transformation()) - d.scaleWidget.setFont(fscl) d.scaleWidget.setMargin(2) text = d.scaleWidget.title() - text.setFont(fttl) d.scaleWidget.setTitle(text) - + d.doAutoScale = True + d.margin = 0.05 d.minValue = 0.0 d.maxValue = 1000.0 - d.stepSize = 0.0 + d.stepSize = 0.0 d.maxMinor = 5 d.maxMajor = 8 d.isValid = False - + self.__axisData[self.yLeft].isEnabled = True self.__axisData[self.yRight].isEnabled = False self.__axisData[self.xBottom].isEnabled = True self.__axisData[self.xTop].isEnabled = False def deleteAxesData(self): - #XXX Is is really necessary in Python? (pure transcription of C++) - for axisId in range(self.axisCnt): + # XXX Is is really necessary in Python? (pure transcription of C++) + for axisId in self.AXES: self.__axisData[axisId].scaleEngine = None self.__axisData[axisId] = None def axisWidget(self, axisId): + """ + :param int axisId: Axis index + :return: Scale widget of the specified axis, or None if axisId is invalid. + """ if self.axisValid(axisId): return self.__axisData[axisId].scaleWidget - + def setAxisScaleEngine(self, axisId, scaleEngine): + """ + Change the scale engine for an axis + + :param int axisId: Axis index + :param qwt.scale_engine.QwtScaleEngine scaleEngine: Scale engine + + .. seealso:: + + :py:meth:`axisScaleEngine()` + """ if self.axisValid(axisId) and scaleEngine is not None: d = self.__axisData[axisId] d.scaleEngine = scaleEngine self.__axisData[axisId].scaleWidget.setTransformation( - scaleEngine.transformation()) + scaleEngine.transformation() + ) d.isValid = False self.autoRefresh() - + def axisScaleEngine(self, axisId): + """ + :param int axisId: Axis index + :return: Scale engine for a specific axis + + .. seealso:: + + :py:meth:`setAxisScaleEngine()` + """ if self.axisValid(axisId): return self.__axisData[axisId].scaleEngine def axisAutoScale(self, axisId): + """ + :param int axisId: Axis index + :return: True, if autoscaling is enabled + """ if self.axisValid(axisId): return self.__axisData[axisId].doAutoScale - + def axisEnabled(self, axisId): + """ + :param int axisId: Axis index + :return: True, if a specified axis is enabled + """ if self.axisValid(axisId): return self.__axisData[axisId].isEnabled - + def axisFont(self, axisId): + """ + :param int axisId: Axis index + :return: The font of the scale labels for a specified axis + """ if self.axisValid(axisId): return self.axisWidget(axisId).font() else: return QFont() - + def axisMaxMajor(self, axisId): + """ + :param int axisId: Axis index + :return: The maximum number of major ticks for a specified axis + + .. seealso:: + + :py:meth:`setAxisMaxMajor()`, + :py:meth:`qwt.scale_engine.QwtScaleEngine.divideScale()` + """ if self.axisValid(axisId): - return self.axisWidget(axisId).maxMajor + return self.__axisData[axisId].maxMajor else: return 0 def axisMaxMinor(self, axisId): + """ + :param int axisId: Axis index + :return: The maximum number of minor ticks for a specified axis + + .. seealso:: + + :py:meth:`setAxisMaxMinor()`, + :py:meth:`qwt.scale_engine.QwtScaleEngine.divideScale()` + """ if self.axisValid(axisId): - return self.axisWidget(axisId).maxMinor + return self.__axisData[axisId].maxMinor else: return 0 - + def axisScaleDiv(self, axisId): + """ + :param int axisId: Axis index + :return: The scale division of a specified axis + + axisScaleDiv(axisId).lowerBound(), axisScaleDiv(axisId).upperBound() + are the current limits of the axis scale. + + .. seealso:: + + :py:class:`qwt.scale_div.QwtScaleDiv`, + :py:meth:`setAxisScaleDiv()`, + :py:meth:`qwt.scale_engine.QwtScaleEngine.divideScale()` + """ return self.__axisData[axisId].scaleDiv - + def axisScaleDraw(self, axisId): + """ + :param int axisId: Axis index + :return: Specified scaleDraw for axis, or NULL if axis is invalid. + """ if self.axisValid(axisId): return self.axisWidget(axisId).scaleDraw() def axisStepSize(self, axisId): + """ + :param int axisId: Axis index + :return: step size parameter value + + This doesn't need to be the step size of the current scale. + + .. seealso:: + + :py:meth:`setAxisScale()`, + :py:meth:`qwt.scale_engine.QwtScaleEngine.divideScale()` + """ if self.axisValid(axisId): - return self.axisWidget(axisId).stepSize + return self.__axisData[axisId].stepSize else: return 0 - + + def axisMargin(self, axisId): + """ + :param int axisId: Axis index + :return: Relative margin of the axis, as a fraction of the full axis range + + .. seealso:: + + :py:meth:`setAxisMargin()` + """ + if self.axisValid(axisId): + return self.__axisData[axisId].margin + return 0.0 + def axisInterval(self, axisId): + """ + :param int axisId: Axis index + :return: The current interval of the specified axis + + This is only a convenience function for axisScaleDiv(axisId).interval() + + .. seealso:: + + :py:class:`qwt.scale_div.QwtScaleDiv`, + :py:meth:`axisScaleDiv()` + """ if self.axisValid(axisId): - return self.axisWidget(axisId).scaleDiv.interval() + return self.axisScaleDiv(axisId).interval() else: return QwtInterval() def axisTitle(self, axisId): + """ + :param int axisId: Axis index + :return: Title of a specified axis + """ if self.axisValid(axisId): return self.axisWidget(axisId).title() else: return QwtText() - + def enableAxis(self, axisId, tf=True): + """ + Enable or disable a specified axis + + When an axis is disabled, this only means that it is not + visible on the screen. Curves, markers and can be attached + to disabled axes, and transformation of screen coordinates + into values works as normal. + + Only xBottom and yLeft are enabled by default. + + :param int axisId: Axis index + :param bool tf: True (enabled) or False (disabled) + """ if self.axisValid(axisId) and tf != self.__axisData[axisId].isEnabled: self.__axisData[axisId].isEnabled = tf self.updateLayout() - + def invTransform(self, axisId, pos): + """ + Transform the x or y coordinate of a position in the + drawing region into a value. + + :param int axisId: Axis index + :param int pos: position + + .. warning:: + + The position can be an x or a y coordinate, + depending on the specified axis. + """ if self.axisValid(axisId): return self.canvasMap(axisId).invTransform(pos) else: - return 0. - + return 0.0 + def transform(self, axisId, value): + """ + Transform a value into a coordinate in the plotting region + + :param int axisId: Axis index + :param fload value: Value + :return: X or Y coordinate in the plotting region corresponding to the value. + """ if self.axisValid(axisId): return self.canvasMap(axisId).transform(value) else: - return 0. + return 0.0 def setAxisFont(self, axisId, font): + """ + Change the font of an axis + + :param int axisId: Axis index + :param QFont font: Font + + .. warning:: + + This function changes the font of the tick labels, + not of the axis title. + """ if self.axisValid(axisId): return self.axisWidget(axisId).setFont(font) - - def setAxisAutoScale(self, axisId, on): + + def setAxisAutoScale(self, axisId, on=True): + """ + Enable autoscaling for a specified axis + + This member function is used to switch back to autoscaling mode + after a fixed scale has been set. Autoscaling is enabled by default. + + :param int axisId: Axis index + :param bool on: On/Off + + .. seealso:: + + :py:meth:`setAxisScale()`, :py:meth:`setAxisScaleDiv()`, + :py:meth:`updateAxes()` + + .. note:: + + The autoscaling flag has no effect until updateAxes() is executed + ( called by replot() ). + """ if self.axisValid(axisId) and self.__axisData[axisId].doAutoScale != on: self.__axisData[axisId].doAutoScale = on self.autoRefresh() - + def setAxisScale(self, axisId, min_, max_, stepSize=0): + """ + Disable autoscaling and specify a fixed scale for a selected axis. + + In updateAxes() the scale engine calculates a scale division from the + specified parameters, that will be assigned to the scale widget. So + updates of the scale widget usually happen delayed with the next replot. + + :param int axisId: Axis index + :param float min_: Minimum of the scale + :param float max_: Maximum of the scale + :param float stepSize: Major step size. If step == 0, the step size is calculated automatically using the maxMajor setting. + + .. seealso:: + + :py:meth:`setAxisMaxMajor()`, :py:meth:`setAxisAutoScale()`, + :py:meth:`axisStepSize()`, + :py:meth:`qwt.scale_engine.QwtScaleEngine.divideScale()` + """ if self.axisValid(axisId): d = self.__axisData[axisId] d.doAutoScale = False @@ -375,29 +740,108 @@ def setAxisScale(self, axisId, min_, max_, stepSize=0): d.maxValue = max_ d.stepSize = stepSize self.autoRefresh() - + def setAxisScaleDiv(self, axisId, scaleDiv): + """ + Disable autoscaling and specify a fixed scale for a selected axis. + + The scale division will be stored locally only until the next call + of updateAxes(). So updates of the scale widget usually happen delayed with + the next replot. + + :param int axisId: Axis index + :param qwt.scale_div.QwtScaleDiv scaleDiv: Scale division + + .. seealso:: + + :py:meth:`setAxisScale()`, :py:meth:`setAxisAutoScale()` + """ if self.axisValid(axisId): d = self.__axisData[axisId] d.doAutoScale = False d.scaleDiv = scaleDiv d.isValid = True self.autoRefresh() - + def setAxisScaleDraw(self, axisId, scaleDraw): + """ + Set a scale draw + + :param int axisId: Axis index + :param qwt.scale_draw.QwtScaleDraw scaleDraw: Object responsible for drawing scales. + + By passing scaleDraw it is possible to extend QwtScaleDraw + functionality and let it take place in QwtPlot. Please note + that scaleDraw has to be created with new and will be deleted + by the corresponding QwtScale member ( like a child object ). + + .. seealso:: + + :py:class:`qwt.scale_draw.QwtScaleDraw`, + :py:class:`qwt.scale_widget.QwtScaleWigdet` + + .. warning:: + + The attributes of scaleDraw will be overwritten by those of the + previous QwtScaleDraw. + """ if self.axisValid(axisId): self.axisWidget(axisId).setScaleDraw(scaleDraw) self.autoRefresh() - + def setAxisLabelAlignment(self, axisId, alignment): + """ + Change the alignment of the tick labels + + :param int axisId: Axis index + :param Qt.Alignment alignment: Or'd Qt.AlignmentFlags + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtScaleDraw.setLabelAlignment()` + """ if self.axisValid(axisId): self.axisWidget(axisId).setLabelAlignment(alignment) - + def setAxisLabelRotation(self, axisId, rotation): + """ + Rotate all tick labels + + :param int axisId: Axis index + :param float rotation: Angle in degrees. When changing the label rotation, the label alignment might be adjusted too. + + .. seealso:: + + :py:meth:`setLabelRotation()`, :py:meth:`setAxisLabelAlignment()` + """ if self.axisValid(axisId): self.axisWidget(axisId).setLabelRotation(rotation) - + + def setAxisLabelAutoSize(self, axisId, state): + """ + Set tick labels automatic size option (default: on) + + :param int axisId: Axis index + :param bool state: On/off + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtScaleDraw.setLabelAutoSize()` + """ + if self.axisValid(axisId): + self.axisWidget(axisId).setLabelAutoSize(state) + def setAxisMaxMinor(self, axisId, maxMinor): + """ + Set the maximum number of minor scale intervals for a specified axis + + :param int axisId: Axis index + :param int maxMinor: Maximum number of minor steps + + .. seealso:: + + :py:meth:`axisMaxMinor()` + """ if self.axisValid(axisId): maxMinor = max([0, min([maxMinor, 100])]) d = self.__axisData[axisId] @@ -407,6 +851,16 @@ def setAxisMaxMinor(self, axisId, maxMinor): self.autoRefresh() def setAxisMaxMajor(self, axisId, maxMajor): + """ + Set the maximum number of major scale intervals for a specified axis + + :param int axisId: Axis index + :param int maxMajor: Maximum number of major steps + + .. seealso:: + + :py:meth:`axisMaxMajor()` + """ if self.axisValid(axisId): maxMajor = max([1, min([maxMajor, 10000])]) d = self.__axisData[axisId] @@ -415,13 +869,67 @@ def setAxisMaxMajor(self, axisId, maxMajor): d.isValid = False self.autoRefresh() + def setAxisMargin(self, axisId, margin): + """ + Set the relative margin of the axis, as a fraction of the full axis range + + :param int axisId: Axis index + :param float margin: Relative margin (float between 0 and 1) + + .. seealso:: + + :py:meth:`axisMargin()` + """ + if not isinstance(margin, float) or margin < 0.0 or margin > 1.0: + raise ValueError("margin must be a float between 0 and 1") + if self.axisValid(axisId): + d = self.__axisData[axisId] + if margin != d.margin: + d.margin = margin + d.isValid = False + self.autoRefresh() + def setAxisTitle(self, axisId, title): + """ + Change the title of a specified axis + + :param int axisId: Axis index + :param title: axis title + :type title: qwt.text.QwtText or str + """ if self.axisValid(axisId): self.axisWidget(axisId).setTitle(title) self.updateLayout() def updateAxes(self): - intv = [QwtInterval() for _i in range(self.axisCnt)] + """ + Rebuild the axes scales + + In case of autoscaling the boundaries of a scale are calculated + from the bounding rectangles of all plot items, having the + `QwtPlotItem.AutoScale` flag enabled (`QwtScaleEngine.autoScale()`). + Then a scale division is calculated (`QwtScaleEngine.didvideScale()`) + and assigned to scale widget. + + When the scale boundaries have been assigned with `setAxisScale()` a + scale division is calculated (`QwtScaleEngine.didvideScale()`) + for this interval and assigned to the scale widget. + + When the scale has been set explicitly by `setAxisScaleDiv()` the + locally stored scale division gets assigned to the scale widget. + + The scale widget indicates modifications by emitting a + `QwtScaleWidget.scaleDivChanged()` signal. + + `updateAxes()` is usually called by `replot()`. + + .. seealso:: + + :py:meth:`setAxisAutoScale()`, :py:meth:`setAxisScale()`, + :py:meth:`setAxisScaleDiv()`, :py:meth:`replot()`, + :py:meth:`QwtPlotItem.boundingRect()` + """ + intv = [QwtInterval() for _i in self.AXES] itmList = self.itemList() for item in itmList: if not item.testItemAttribute(QwtPlotItem.AutoScale): @@ -430,11 +938,12 @@ def updateAxes(self): continue if self.axisAutoScale(item.xAxis()) or self.axisAutoScale(item.yAxis()): rect = item.boundingRect() - if rect.width() >= 0.: + if rect.width() >= 0.0: intv[item.xAxis()] |= QwtInterval(rect.left(), rect.right()) - if rect.height() >= 0.: + if rect.height() >= 0.0: intv[item.yAxis()] |= QwtInterval(rect.top(), rect.bottom()) - for axisId in range(self.axisCnt): + + for axisId in self.AXES: d = self.__axisData[axisId] minValue = d.minValue maxValue = d.maxValue @@ -443,24 +952,44 @@ def updateAxes(self): d.isValid = False minValue = intv[axisId].minValue() maxValue = intv[axisId].maxValue() - d.scaleEngine.autoScale(d.maxMajor, minValue, maxValue, stepSize) + minValue, maxValue, stepSize = d.scaleEngine.autoScale( + d.maxMajor, minValue, maxValue, stepSize, d.margin + ) if not d.isValid: - d.scaleDiv = d.scaleEngine.divideScale(minValue, maxValue, - d.maxMajor, d.maxMinor, stepSize) + d.scaleDiv = d.scaleEngine.divideScale( + minValue, maxValue, d.maxMajor, d.maxMinor, stepSize + ) d.isValid = True scaleWidget = self.axisWidget(axisId) scaleWidget.setScaleDiv(d.scaleDiv) - #TODO: see when it is *really* necessary to update border dist -# startDist, endDist = scaleWidget.getBorderDistHint() -# scaleWidget.setBorderDist(startDist, endDist) + # It is *really* necessary to update border dist! + # Otherwise, when tick labels are large enough, the ticks + # may not be aligned with canvas grid. + # See the following issues for more details: + # https://github.com/PlotPyStack/guiqwt/issues/57 + # https://github.com/PlotPyStack/PythonQwt/issues/30 + startDist, endDist = scaleWidget.getBorderDistHint() + scaleWidget.setBorderDist(startDist, endDist) for item in itmList: if item.testItemInterest(QwtPlotItem.ScaleInterest): - item.updateScaleDiv(self.axisScaleDiv(item.xAxis()), - self.axisScaleDiv(item.yAxis())) - + item.updateScaleDiv( + self.axisScaleDiv(item.xAxis()), self.axisScaleDiv(item.yAxis()) + ) + def setCanvas(self, canvas): + """ + Set the drawing canvas of the plot widget. + + The default canvas is a `QwtPlotCanvas`. + + :param QWidget canvas: Canvas Widget + + .. seealso:: + + :py:meth:`canvas()` + """ if canvas == self.__data.canvas: return self.__data.canvas = canvas @@ -469,34 +998,70 @@ def setCanvas(self, canvas): canvas.installEventFilter(self) if self.isVisible(): canvas.show() - + def event(self, event): - ok = QFrame.event(self, event) if event.type() == QEvent.LayoutRequest: self.updateLayout() elif event.type() == QEvent.PolishRequest: self.replot() - return ok + return QFrame.event(self, event) def eventFilter(self, obj, event): if obj is self.__data.canvas: if event.type() == QEvent.Resize: self.updateCanvasMargins() - elif event.type() == 178:#QEvent.ContentsRectChange: + elif event.type() == 178: # QEvent.ContentsRectChange: self.updateLayout() return QFrame.eventFilter(self, obj, event) - + def autoRefresh(self): + """Replots the plot if :py:meth:`autoReplot()` is True.""" if self.__data.autoReplot: self.replot() - - def setAutoReplot(self, tf): + + def setAutoReplot(self, tf=True): + """ + Set or reset the autoReplot option + + If the autoReplot option is set, the plot will be + updated implicitly by manipulating member functions. + Since this may be time-consuming, it is recommended + to leave this option switched off and call :py:meth:`replot()` + explicitly if necessary. + + The autoReplot option is set to false by default, which + means that the user has to call :py:meth:`replot()` in order + to make changes visible. + + :param bool tf: True or False. Defaults to True. + + .. seealso:: + + :py:meth:`autoReplot()` + """ self.__data.autoReplot = tf - + def autoReplot(self): + """ + :return: True if the autoReplot option is set. + + .. seealso:: + + :py:meth:`setAutoReplot()` + """ return self.__data.autoReplot - + def setTitle(self, title): + """ + Change the plot's title + + :param title: New title + :type title: str or qwt.text.QwtText + + .. seealso:: + + :py:meth:`title()` + """ current_title = self.__data.titleLabel.text() if isinstance(title, QwtText) and current_title == title: return @@ -504,14 +1069,34 @@ def setTitle(self, title): return self.__data.titleLabel.setText(title) self.updateLayout() - + def title(self): + """ + :return: Title of the plot + + .. seealso:: + + :py:meth:`setTitle()` + """ return self.__data.titleLabel.text() - + def titleLabel(self): + """ + :return: Title label widget. + """ return self.__data.titleLabel - + def setFooter(self, text): + """ + Change the text the footer + + :param text: New text of the footer + :type text: str or qwt.text.QwtText + + .. seealso:: + + :py:meth:`footer()` + """ current_footer = self.__data.footerLabel.text() if isinstance(text, QwtText) and current_footer == text: return @@ -519,93 +1104,170 @@ def setFooter(self, text): return self.__data.footerLabel.setText(text) self.updateLayout() - + def footer(self): + """ + :return: Text of the footer + + .. seealso:: + + :py:meth:`setFooter()` + """ return self.__data.footerLabel.text() - + def footerLabel(self): + """ + :return: Footer label widget. + """ return self.__data.footerLabel def setPlotLayout(self, layout): + """ + Assign a new plot layout + + :param layout: Layout + :type layout: qwt.plot_layout.QwtPlotLayout + + .. seealso:: + + :py:meth:`plotLayout()` + """ if layout != self.__data.layout: self.__data.layout = layout self.updateLayout() - + def plotLayout(self): + """ + :return: the plot's layout + + .. seealso:: + + :py:meth:`setPlotLayout()` + """ return self.__data.layout - + def legend(self): + """ + :return: the plot's legend + + .. seealso:: + + :py:meth:`insertLegend()` + """ return self.__data.legend - + def canvas(self): + """ + :return: the plot's canvas + """ return self.__data.canvas - + def sizeHint(self): + """ + :return: Size hint for the plot widget + + .. seealso:: + + :py:meth:`minimumSizeHint()` + """ dw = dh = 0 - for axisId in range(self.axisCnt): + for axisId in self.AXES: if self.axisEnabled(axisId): niceDist = 40 scaleWidget = self.axisWidget(axisId) scaleDiv = scaleWidget.scaleDraw().scaleDiv() majCnt = len(scaleDiv.ticks(QwtScaleDiv.MajorTick)) if axisId in (self.yLeft, self.yRight): - hDiff = (majCnt-1)*niceDist-scaleWidget.minimumSizeHint().height() + hDiff = ( + majCnt - 1 + ) * niceDist - scaleWidget.minimumSizeHint().height() if hDiff > dh: dh = hDiff else: - wDiff = (majCnt-1)*niceDist-scaleWidget.minimumSizeHint().width() + wDiff = ( + majCnt - 1 + ) * niceDist - scaleWidget.minimumSizeHint().width() if wDiff > dw: dw = wDiff return self.minimumSizeHint() + QSize(dw, dh) - + def minimumSizeHint(self): + """ + :return: Return a minimum size hint + """ hint = self.__data.layout.minimumSizeHint(self) - hint += QSize(2*self.frameWidth(), 2*self.frameWidth()) + hint += QSize(2 * self.frameWidth(), 2 * self.frameWidth()) return hint - + def resizeEvent(self, e): QFrame.resizeEvent(self, e) self.updateLayout() - + def replot(self): + """ + Redraw the plot + + If the `autoReplot` option is not set (which is the default) + or if any curves are attached to raw data, the plot has to + be refreshed explicitly in order to make changes visible. + + .. seealso:: + + :py:meth:`updateAxes()`, :py:meth:`setAutoReplot()` + """ doAutoReplot = self.autoReplot() self.setAutoReplot(False) self.updateAxes() - + + # Maybe the layout needs to be updated, because of changed + # axes labels. We need to process them here before painting + # to avoid that scales and canvas get out of sync. QApplication.sendPostedEvents(self, QEvent.LayoutRequest) - + if self.__data.canvas: try: self.__data.canvas.replot() except (AttributeError, TypeError): self.__data.canvas.update(self.__data.canvas.contentsRect()) - + self.setAutoReplot(doAutoReplot) def get_layout_state(self): - return (self.contentsRect(), - self.__data.titleLabel.text(), self.__data.footerLabel.text(), - [(self.axisEnabled(axisId), self.axisTitle(axisId).text()) - for axisId in range(self.axisCnt)], - self.__data.legend) - + return ( + self.contentsRect(), + self.__data.titleLabel.text(), + self.__data.footerLabel.text(), + [ + (self.axisEnabled(axisId), self.axisTitle(axisId).text()) + for axisId in self.AXES + ], + self.__data.legend, + ) + def updateLayout(self): -# state = self.get_layout_state() -# if self.__layout_state is not None and\ -# state == self.__layout_state: -# return -# self.__layout_state = state + """ + Adjust plot content to its current size. + + .. seealso:: + + :py:meth:`resizeEvent()` + """ + # state = self.get_layout_state() + # if self.__layout_state is not None and\ + # state == self.__layout_state: + # return + # self.__layout_state = state self.__data.layout.activate(self, self.contentsRect()) - + titleRect = self.__data.layout.titleRect().toRect() footerRect = self.__data.layout.footerRect().toRect() - scaleRect = [None] * self.axisCnt - for axisId in range(self.axisCnt): - scaleRect[axisId] = self.__data.layout.scaleRect(axisId).toRect() + scaleRect = [ + self.__data.layout.scaleRect(axisId).toRect() for axisId in self.AXES + ] legendRect = self.__data.layout.legendRect().toRect() canvasRect = self.__data.layout.canvasRect().toRect() - + if self.__data.titleLabel.text(): self.__data.titleLabel.setGeometry(titleRect) if not self.__data.titleLabel.isVisibleTo(self): @@ -619,101 +1281,173 @@ def updateLayout(self): self.__data.footerLabel.show() else: self.__data.footerLabel.hide() - - for axisId in range(self.axisCnt): + + for axisId in self.AXES: + scaleWidget = self.axisWidget(axisId) if self.axisEnabled(axisId): - self.axisWidget(axisId).setGeometry(scaleRect[axisId]) - - if axisId in (self.xBottom, self.xTop): - r = QRegion(scaleRect[axisId]) - if self.axisEnabled(self.yLeft): - r = r.subtracted(QRegion(scaleRect[self.yLeft])) - if self.axisEnabled(self.yRight): - r = r.subtracted(QRegion(scaleRect[self.yRight])) - r.translate(-scaleRect[axisId].x(), -scaleRect[axisId].y()) - - self.axisWidget(axisId).setMask(r) - - if not self.axisWidget(axisId).isVisibleTo(self): - self.axisWidget(axisId).show() - + if scaleRect[axisId] != scaleWidget.geometry(): + scaleWidget.setGeometry(scaleRect[axisId]) + startDist, endDist = scaleWidget.getBorderDistHint() + scaleWidget.setBorderDist(startDist, endDist) + + # ------------------------------------------------------------- + # XXX: The following was commented to fix issue #35 + # Note: the same code part in Qwt's original source code is + # annotated with the mention "do we need this code any + # longer ???"... I guess not :) + # if axisId in (self.xBottom, self.xTop): + # r = QRegion(scaleRect[axisId]) + # if self.axisEnabled(self.yLeft): + # r = r.subtracted(QRegion(scaleRect[self.yLeft])) + # if self.axisEnabled(self.yRight): + # r = r.subtracted(QRegion(scaleRect[self.yRight])) + # r.translate(-scaleRect[axisId].x(), -scaleRect[axisId].y()) + # scaleWidget.setMask(r) + # ------------------------------------------------------------- + + if not scaleWidget.isVisibleTo(self): + scaleWidget.show() else: - self.axisWidget(axisId).hide() - + scaleWidget.hide() + if self.__data.legend: if self.__data.legend.isEmpty(): self.__data.legend.hide() else: self.__data.legend.setGeometry(legendRect) self.__data.legend.show() - + self.__data.canvas.setGeometry(canvasRect) - + def getCanvasMarginsHint(self, maps, canvasRect): - """Calculate the canvas margins - (``margins`` is a list which is modified inplace)""" - left = top = right = bottom = -1. + """ + Calculate the canvas margins + + :param list maps: `QwtPlot.axisCnt` maps, mapping between plot and paint device coordinates + :param QRectF canvasRect: Bounding rectangle where to paint + + Plot items might indicate, that they need some extra space + at the borders of the canvas by the `QwtPlotItem.Margins` flag. + + .. seealso:: + + :py:meth:`updateCanvasMargins()`, :py:meth:`getCanvasMarginHint()` + """ + left = top = right = bottom = -1.0 for item in self.itemList(): if item.testItemAttribute(QwtPlotItem.Margins): - m = item.getCanvasMarginHint(maps, canvasRect) + m = item.getCanvasMarginHint( + maps[item.xAxis()], maps[item.yAxis()], canvasRect + ) left = max([left, m[self.yLeft]]) top = max([top, m[self.xTop]]) right = max([right, m[self.yRight]]) bottom = max([bottom, m[self.xBottom]]) return left, top, right, bottom - + def updateCanvasMargins(self): - maps = [self.canvasMap(axisId) for axisId in range(self.axisCnt)] + """ + Update the canvas margins + + Plot items might indicate, that they need some extra space + at the borders of the canvas by the `QwtPlotItem.Margins` flag. + + .. seealso:: + + :py:meth:`getCanvasMarginsHint()`, + :py:meth:`QwtPlotItem.getCanvasMarginHint()` + """ + maps = [self.canvasMap(axisId) for axisId in self.AXES] margins = self.getCanvasMarginsHint(maps, self.canvas().contentsRect()) - + doUpdate = False - - for axisId in range(self.axisCnt): - if margins[axisId] >= 0.: - m = np.ceil(margins[axisId]) + + for axisId in self.AXES: + if margins[axisId] >= 0.0: + m = math.ceil(margins[axisId]) self.plotLayout().setCanvasMargin(m, axisId) doUpdate = True - + if doUpdate: self.updateLayout() - + def drawCanvas(self, painter): - maps = [self.canvasMap(axisId) for axisId in range(self.axisCnt)] - self.drawItems(painter, self.__data.canvas.contentsRect(), maps) - + """ + Redraw the canvas. + + :param QPainter painter: Painter used for drawing + + .. warning:: + + drawCanvas calls drawItems what is also used + for printing. Applications that like to add individual + plot items better overload drawItems() + + .. seealso:: + + :py:meth:`getCanvasMarginsHint()`, + :py:meth:`QwtPlotItem.getCanvasMarginHint()` + """ + maps = [self.canvasMap(axisId) for axisId in self.AXES] + self.drawItems(painter, QRectF(self.__data.canvas.contentsRect()), maps) + def drawItems(self, painter, canvasRect, maps): + """ + Redraw the canvas. + + :param QPainter painter: Painter used for drawing + :param QRectF canvasRect: Bounding rectangle where to paint + :param list maps: `QwtPlot.axisCnt` maps, mapping between plot and paint device coordinates + + .. note:: + + Usually canvasRect is `contentsRect()` of the plot canvas. + Due to a bug in Qt this rectangle might be wrong for certain + frame styles ( f.e `QFrame.Box` ) and it might be necessary to + fix the margins manually using `QWidget.setContentsMargins()` + """ for item in self.itemList(): if item and item.isVisible(): painter.save() - painter.setRenderHint(QPainter.Antialiasing, - item.testRenderHint(QwtPlotItem.RenderAntialiased)) - painter.setRenderHint(QPainter.HighQualityAntialiasing, - item.testRenderHint(QwtPlotItem.RenderAntialiased)) - item.draw(painter, maps[item.xAxis()], maps[item.yAxis()], - canvasRect) + painter.setRenderHint( + QPainter.Antialiasing, + item.testRenderHint(QwtPlotItem.RenderAntialiased), + ) + item.draw(painter, maps[item.xAxis()], maps[item.yAxis()], canvasRect) painter.restore() def canvasMap(self, axisId): + """ + :param int axisId: Axis + :return: Map for the axis on the canvas. With this map pixel coordinates can translated to plot coordinates and vice versa. + + .. seealso:: + + :py:class:`qwt.scale_map.QwtScaleMap`, + :py:meth:`transform()`, :py:meth:`invTransform()` + """ map_ = QwtScaleMap() if not self.__data.canvas: return map_ - + map_.setTransformation(self.axisScaleEngine(axisId).transformation()) sd = self.axisScaleDiv(axisId) + if sd is None: + return map_ map_.setScaleInterval(sd.lowerBound(), sd.upperBound()) - + if self.axisEnabled(axisId): s = self.axisWidget(axisId) if axisId in (self.yLeft, self.yRight): y = s.y() + s.startBorderDist() - self.__data.canvas.y() h = s.height() - s.startBorderDist() - s.endBorderDist() - map_.setPaintInterval(y+h, y) + map_.setPaintInterval(y + h, y) else: x = s.x() + s.startBorderDist() - self.__data.canvas.x() w = s.width() - s.startBorderDist() - s.endBorderDist() - map_.setPaintInterval(x, x+w) + map_.setPaintInterval(x, x + w) else: canvasRect = self.__data.canvas.contentsRect() if axisId in (self.yLeft, self.yRight): @@ -723,8 +1457,9 @@ def canvasMap(self, axisId): bottom = 0 if not self.plotLayout().alignCanvasToScale(self.xBottom): bottom = self.plotLayout().canvasMargin(self.xBottom) - map_.setPaintInterval(canvasRect.bottom()-bottom, - canvasRect.top()+top) + map_.setPaintInterval( + canvasRect.bottom() - bottom, canvasRect.top() + top + ) else: left = 0 if not self.plotLayout().alignCanvasToScale(self.yLeft): @@ -732,39 +1467,104 @@ def canvasMap(self, axisId): right = 0 if not self.plotLayout().alignCanvasToScale(self.yRight): right = self.plotLayout().canvasMargin(self.yRight) - map_.setPaintInterval(canvasRect.left()+left, - canvasRect.right()-right) + map_.setPaintInterval( + canvasRect.left() + left, canvasRect.right() - right + ) return map_ - + def setCanvasBackground(self, brush): + """ + Change the background of the plotting area + + Sets brush to `QPalette.Window` of all color groups of + the palette of the canvas. Using `canvas().setPalette()` + is a more powerful way to set these colors. + + :param QBrush brush: New background brush + + .. seealso:: + + :py:meth:`canvasBackground()` + """ pal = self.__data.canvas.palette() - pal.setBrush(QPalette.Window, brush) + pal.setBrush(QPalette.Window, QBrush(brush)) self.canvas().setPalette(pal) - + def canvasBackground(self): - return self.canvas().palette().brush(QPalette.Normal, QPalette.Window) - - def axisValid(self, axisId): - return axisId in range(QwtPlot.axisCnt) - + """ + :return: Background brush of the plotting area. + + .. seealso:: + + :py:meth:`setCanvasBackground()` + """ + return self.canvas().palette().brush(QPalette.Active, QPalette.Window) + + def axisValid(self, axis_id): + """ + :param int axis_id: Axis + :return: True if the specified axis exists, otherwise False + """ + return axis_id in QwtPlot.AXES + def insertLegend(self, legend, pos=None, ratio=-1): + """ + Insert a legend + + If the position legend is `QwtPlot.LeftLegend` or `QwtPlot.RightLegend` + the legend will be organized in one column from top to down. + Otherwise the legend items will be placed in a table + with a best fit number of columns from left to right. + + insertLegend() will set the plot widget as parent for the legend. + The legend will be deleted in the destructor of the plot or when + another legend is inserted. + + Legends, that are not inserted into the layout of the plot widget + need to connect to the legendDataChanged() signal. Calling updateLegend() + initiates this signal for an initial update. When the application code + wants to implement its own layout this also needs to be done for + rendering plots to a document ( see QwtPlotRenderer ). + + :param qwt.legend.QwtAbstractLegend legend: Legend + :param QwtPlot.LegendPosition pos: The legend's position. + :param float ratio: Ratio between legend and the bounding rectangle of title, canvas and axes + + .. note:: + + For top/left position the number of columns will be limited to 1, + otherwise it will be set to unlimited. + + .. note:: + + The legend will be shrunk if it would need more space than the + given ratio. The ratio is limited to ]0.0 .. 1.0]. + In case of <= 0.0 it will be reset to the default ratio. + The default vertical/horizontal ratio is 0.33/0.5. + + .. seealso:: + + :py:meth:`legend()`, + :py:meth:`qwt.plot_layout.QwtPlotLayout.legendPosition()`, + :py:meth:`qwt.plot_layout.QwtPlotLayout.setLegendPosition()` + """ if pos is None: pos = self.RightLegend self.__data.layout.setLegendPosition(pos, ratio) if legend != self.__data.legend: if self.__data.legend and self.__data.legend.parent() is self: + self.__data.legend.setParent(None) del self.__data.legend self.__data.legend = legend if self.__data.legend: - self.SIG_LEGEND_DATA_CHANGED.connect( - self.__data.legend.updateLegend) + self.legendDataChanged.connect(self.__data.legend.updateLegend) if self.__data.legend.parent() is not self: self.__data.legend.setParent(self) - - qwtEnableLegendItems(self, False) + + self.legendDataChanged.disconnect(self.updateLegendItems) self.updateLegend() - qwtEnableLegendItems(self, True) - + self.legendDataChanged.connect(self.updateLegendItems) + lpos = self.__data.layout.legendPosition() if legend is not None: @@ -773,7 +1573,7 @@ def insertLegend(self, legend, pos=None, ratio=-1): legend.setMaxColumns(1) elif lpos in (self.TopLegend, self.BottomLegend): legend.setMaxColumns(0) - + previousInChain = None if lpos == self.LeftLegend: previousInChain = self.axisWidget(QwtPlot.xTop) @@ -783,15 +1583,25 @@ def insertLegend(self, legend, pos=None, ratio=-1): previousInChain = self.axisWidget(QwtPlot.yRight) elif lpos == self.BottomLegend: previousInChain = self.footerLabel() - - if previousInChain: - qwtSetTabOrder(previousInChain, legend, True) - + + if previousInChain is not None: + qwtSetTabOrder(previousInChain, legend, True) + self.updateLayout() - + def updateLegend(self, plotItem=None): + """ + If plotItem is None, emit QwtPlot.legendDataChanged for all + plot item. Otherwise, emit the signal for passed plot item. + + :param qwt.plot.QwtPlotItem plotItem: Plot item + + .. seealso:: + + :py:meth:`QwtPlotItem.legendData()`, :py:data:`QwtPlot.legendDataChanged` + """ if plotItem is None: - items = [plotItem for plotItem in self.itemList()] + items = list(self.itemList()) else: items = [plotItem] for plotItem in items: @@ -800,124 +1610,276 @@ def updateLegend(self, plotItem=None): legendData = [] if plotItem.testItemAttribute(QwtPlotItem.Legend): legendData = plotItem.legendData() - self.SIG_LEGEND_DATA_CHANGED.emit(plotItem, legendData) + self.legendDataChanged.emit(plotItem, legendData) def updateLegendItems(self, plotItem, legendData): + """ + Update all plot items interested in legend attributes + + Call `QwtPlotItem.updateLegend()`, when the + `QwtPlotItem.LegendInterest` flag is set. + + :param qwt.plot.QwtPlotItem plotItem: Plot item + :param list legendData: Entries to be displayed for the plot item ( usually 1 ) + + .. seealso:: + + :py:meth:`QwtPlotItem.LegendInterest()`, + :py:meth:`QwtPlotItem.updateLegend` + """ if plotItem is not None: for item in self.itemList(): if item.testItemInterest(QwtPlotItem.LegendInterest): item.updateLegend(plotItem, legendData) - + def attachItem(self, plotItem, on): + """ + Attach/Detach a plot item + + :param qwt.plot.QwtPlotItem plotItem: Plot item + :param bool on: When true attach the item, otherwise detach it + """ if plotItem.testItemInterest(QwtPlotItem.LegendInterest): for item in self.itemList(): legendData = [] if on and item.testItemAttribute(QwtPlotItem.Legend): legendData = item.legendData() plotItem.updateLegend(item, legendData) - + if on: self.insertItem(plotItem) else: self.removeItem(plotItem) - - self.SIG_ITEM_ATTACHED.emit(plotItem, on) - + + self.itemAttached.emit(plotItem, on) + if plotItem.testItemAttribute(QwtPlotItem.Legend): if on: self.updateLegend(plotItem) else: - self.SIG_LEGEND_DATA_CHANGED.emit(plotItem, []) - + self.legendDataChanged.emit(plotItem, []) + self.autoRefresh() - + def print_(self, printer): + """ + Print plot to printer + + :param printer: Printer + :type printer: QPaintDevice or QPrinter or QSvgGenerator + """ from qwt.plot_renderer import QwtPlotRenderer + renderer = QwtPlotRenderer(self) renderer.renderTo(self, printer) - - def exportTo(self, filename, size=(800, 600), size_mm=None, - resolution=72., format_=None): + + def exportTo( + self, filename, size=(800, 600), size_mm=None, resolution=85, format_=None + ): + """ + Export plot to PDF or image file (SVG, PNG, ...) + + :param str filename: Filename + :param tuple size: (width, height) size in pixels + :param tuple size_mm: (width, height) size in millimeters + :param int resolution: Resolution in dots per Inch (dpi) + :param str format_: File format (PDF, SVG, PNG, ...) + """ if size_mm is None: - size_mm = tuple(25.4*np.array(size)/resolution) + size_mm = tuple(25.4 * np.array(size) / resolution) from qwt.plot_renderer import QwtPlotRenderer + renderer = QwtPlotRenderer(self) renderer.renderDocument(self, filename, size_mm, resolution, format_) -class QwtPlotItem_PrivateData(object): +class QwtPlotItem_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.plot = None self.isVisible = True self.attributes = 0 self.interests = 0 self.renderHints = 0 - self.renderThreadCount = 1 - self.z = 0. + self.z = 0.0 self.xAxis = QwtPlot.xBottom self.yAxis = QwtPlot.yLeft self.legendIconSize = QSize(8, 8) - self.title = None # QwtText + self.title = None # QwtText class QwtPlotItem(object): - + """ + Base class for items on the plot canvas + + A plot item is "something", that can be painted on the plot canvas, + or only affects the scales of the plot widget. They can be categorized as: + + - Representator + + A "Representator" is an item that represents some sort of data + on the plot canvas. The different representator classes are organized + according to the characteristics of the data: + + - :py:class:`qwt.plot_marker.QwtPlotMarker`: Represents a point or a + horizontal/vertical coordinate + - :py:class:`qwt.plot_curve.QwtPlotCurve`: Represents a series of + points + + - Decorators + + A "Decorator" is an item, that displays additional information, that + is not related to any data: + + - :py:class:`qwt.plot_grid.QwtPlotGrid` + + Depending on the `QwtPlotItem.ItemAttribute` flags, an item is included + into autoscaling or has an entry on the legend. + + Before misusing the existing item classes it might be better to + implement a new type of plot item + ( don't implement a watermark as spectrogram ). + Deriving a new type of `QwtPlotItem` primarily means to implement + the `YourPlotItem.draw()` method. + + .. seealso:: + + The cpuplot example shows the implementation of additional plot items. + + .. py:class:: QwtPlotItem([title=None]) + + Constructor + + :param title: Title of the item + :type title: qwt.text.QwtText or str + """ + # enum RttiValues - (Rtti_PlotItem, Rtti_PlotGrid, Rtti_PlotScale, Rtti_PlotLegend, - Rtti_PlotMarker, Rtti_PlotCurve, Rtti_PlotSpectroCurve, - Rtti_PlotIntervalCurve, Rtti_PlotHistogram, Rtti_PlotSpectrogram, - Rtti_PlotSVG, Rtti_PlotTradingCurve, Rtti_PlotBarChart, - Rtti_PlotMultiBarChart, Rtti_PlotShape, Rtti_PlotTextLabel, - Rtti_PlotZone) = list(range(17)) + ( + Rtti_PlotItem, + Rtti_PlotGrid, + Rtti_PlotScale, + Rtti_PlotLegend, + Rtti_PlotMarker, + Rtti_PlotCurve, + Rtti_PlotSpectroCurve, + Rtti_PlotIntervalCurve, + Rtti_PlotHistogram, + Rtti_PlotSpectrogram, + Rtti_PlotSVG, + Rtti_PlotTradingCurve, + Rtti_PlotBarChart, + Rtti_PlotMultiBarChart, + Rtti_PlotShape, + Rtti_PlotTextLabel, + Rtti_PlotZone, + ) = list(range(17)) Rtti_PlotUserItem = 1000 - + # enum ItemAttribute Legend = 0x01 AutoScale = 0x02 Margins = 0x04 - + # enum ItemInterest ScaleInterest = 0x01 LegendInterest = 0x02 - + # enum RenderHint RenderAntialiased = 0x1 - - def __init__(self, title=None): + + def __init__(self, title=None, icon=None): """title: QwtText""" if title is None: title = QwtText("") - if hasattr(title, 'capitalize'): # avoids dealing with Py3K compat. + if hasattr(title, "capitalize"): # avoids dealing with Py3K compat. title = QwtText(title) assert isinstance(title, QwtText) self.__data = QwtPlotItem_PrivateData() self.__data.title = title + self.__data.icon = icon def attach(self, plot): + """ + Attach the item to a plot. + + This method will attach a `QwtPlotItem` to the `QwtPlot` argument. + It will first detach the `QwtPlotItem` from any plot from a previous + call to attach (if necessary). If a None argument is passed, it will + detach from any `QwtPlot` it was attached to. + + :param qwt.plot.QwtPlot plot: Plot widget + + .. seealso:: + + :py:meth:`detach()` + """ if plot is self.__data.plot: return - + if self.__data.plot: self.__data.plot.attachItem(self, False) - + self.__data.plot = plot - + if self.__data.plot: self.__data.plot.attachItem(self, True) - + def detach(self): + """ + Detach the item from a plot. + + This method detaches a `QwtPlotItem` from any `QwtPlot` it has been + associated with. + + .. seealso:: + + :py:meth:`attach()` + """ self.attach(None) - + def rtti(self): + """ + Return rtti for the specific class represented. `QwtPlotItem` is + simply a virtual interface class, and base classes will implement + this method with specific rtti values so a user can differentiate + them. + + :return: rtti value + """ return self.Rtti_PlotItem - + def plot(self): + """ + :return: attached plot + """ return self.__data.plot - + def z(self): + """ + Plot items are painted in increasing z-order. + + :return: item z order + + .. seealso:: + + :py:meth:`setZ()`, :py:meth:`QwtPlotDict.itemList()` + """ return self.__data.z - + def setZ(self, z): + """ + Set the z value + + Plot items are painted in increasing z-order. + + :param float z: Z-value + + .. seealso:: + + :py:meth:`z()`, :py:meth:`QwtPlotDict.itemList()` + """ if self.__data.z != z: if self.__data.plot: self.__data.plot.attachItem(self, False) @@ -925,18 +1887,45 @@ def setZ(self, z): if self.__data.plot: self.__data.plot.attachItem(self, True) self.itemChanged() - + def setTitle(self, title): + """ + Set a new title + + :param title: Title + :type title: qwt.text.QwtText or str + + .. seealso:: + + :py:meth:`title()` + """ if not isinstance(title, QwtText): title = QwtText(title) if self.__data.title != title: self.__data.title = title self.legendChanged() - + def title(self): + """ + :return: Title of the item + + .. seealso:: + + :py:meth:`setTitle()` + """ return self.__data.title - + def setItemAttribute(self, attribute, on=True): + """ + Toggle an item attribute + + :param int attribute: Attribute type + :param bool on: True/False + + .. seealso:: + + :py:meth:`testItemAttribute()` + """ if bool(self.__data.attributes & attribute) != on: if on: self.__data.attributes |= attribute @@ -945,81 +1934,191 @@ def setItemAttribute(self, attribute, on=True): if attribute == QwtPlotItem.Legend: self.legendChanged() self.itemChanged() - + def testItemAttribute(self, attribute): + """ + Test an item attribute + + :param int attribute: Attribute type + :return: True/False + + .. seealso:: + + :py:meth:`setItemAttribute()` + """ return bool(self.__data.attributes & attribute) - - def setItemInterest(self, interest, on): + + def setItemInterest(self, interest, on=True): + """ + Toggle an item interest + + :param int attribute: Interest type + :param bool on: True/False + + .. seealso:: + + :py:meth:`testItemInterest()` + """ if bool(self.__data.interests & interest) != on: if on: self.__data.interests |= interest else: self.__data.interests &= ~interest self.itemChanged() - + def testItemInterest(self, interest): + """ + Test an item interest + + :param int attribute: Interest type + :return: True/False + + .. seealso:: + + :py:meth:`setItemInterest()` + """ return bool(self.__data.interests & interest) - + def setRenderHint(self, hint, on=True): + """ + Toggle a render hint + + :param int hint: Render hint + :param bool on: True/False + + .. seealso:: + + :py:meth:`testRenderHint()` + """ if bool(self.__data.renderHints & hint) != on: if on: self.__data.renderHints |= hint else: self.__data.renderHints &= ~hint self.itemChanged() - + def testRenderHint(self, hint): + """ + Test a render hint + + :param int attribute: Render hint + :return: True/False + + .. seealso:: + + :py:meth:`setRenderHint()` + """ return bool(self.__data.renderHints & hint) - - def setRenderThreadCount(self, numThreads): - self.__data.renderThreadCount = numThreads - - def renderThreadCount(self): - return self.__data.renderThreadCount - + def setLegendIconSize(self, size): + """ + Set the size of the legend icon + + The default setting is 8x8 pixels + + :param QSize size: Size + + .. seealso:: + + :py:meth:`legendIconSize()`, :py:meth:`legendIcon()` + """ if self.__data.legendIconSize != size: self.__data.legendIconSize = size self.legendChanged() - + def legendIconSize(self): + """ + :return: Legend icon size + + .. seealso:: + + :py:meth:`setLegendIconSize()`, :py:meth:`legendIcon()` + """ return self.__data.legendIconSize - + def legendIcon(self, index, size): + """ + :param int index: Index of the legend entry (usually there is only one) + :param QSizeF size: Icon size + :return: Icon representing the item on the legend + + The default implementation returns an invalid icon + + .. seealso:: + + :py:meth:`setLegendIconSize()`, :py:meth:`legendData()` + """ return QwtGraphic() - - def defaultIcon(brush, size): - icon = QwtGraphic() - if not size.isEmpty(): - icon.setDefaultSize(size) - r = QRectF(0, 0, size.width(), size.height()) - painter = QPainter(icon) - painter.fillRect(r, brush) - return icon - + def show(self): + """Show the item""" self.setVisible(True) - + def hide(self): + """Hide the item""" self.setVisible(False) - + def setVisible(self, on): + """ + Show/Hide the item + + :param bool on: Show if True, otherwise hide + + .. seealso:: + + :py:meth:`isVisible()`, :py:meth:`show()`, :py:meth:`hide()` + """ if on != self.__data.isVisible: self.__data.isVisible = on self.itemChanged() - + def isVisible(self): + """ + :return: True if visible + + .. seealso:: + + :py:meth:`setVisible()`, :py:meth:`show()`, :py:meth:`hide()` + """ return self.__data.isVisible - + def itemChanged(self): + """ + Update the legend and call `QwtPlot.autoRefresh()` for the + parent plot. + + .. seealso:: + + :py:meth:`QwtPlot.legendChanged()`, :py:meth:`QwtPlot.autoRefresh()` + """ if self.__data.plot: self.__data.plot.autoRefresh() - + def legendChanged(self): + """ + Update the legend of the parent plot. + + .. seealso:: + + :py:meth:`QwtPlot.updateLegend()`, :py:meth:`itemChanged()` + """ if self.testItemAttribute(QwtPlotItem.Legend) and self.__data.plot: self.__data.plot.updateLegend(self) - + def setAxes(self, xAxis, yAxis): + """ + Set X and Y axis + + The item will painted according to the coordinates of its Axes. + + :param int xAxis: X Axis (`QwtPlot.xBottom` or `QwtPlot.xTop`) + :param int yAxis: Y Axis (`QwtPlot.yLeft` or `QwtPlot.yRight`) + + .. seealso:: + + :py:meth:`setXAxis()`, :py:meth:`setYAxis()`, + :py:meth:`xAxis()`, :py:meth:`yAxis()` + """ if xAxis == QwtPlot.xBottom or xAxis == QwtPlot.xTop: self.__data.xAxis = xAxis if yAxis == QwtPlot.yLeft or yAxis == QwtPlot.yRight: @@ -1027,35 +2126,121 @@ def setAxes(self, xAxis, yAxis): self.itemChanged() def setAxis(self, xAxis, yAxis): + """ + Set X and Y axis + + .. warning:: + + `setAxis` has been removed in Qwt6: please use + :py:meth:`setAxes()` instead + """ import warnings - warnings.warn("`setAxis` has been removed in Qwt6: "\ - "please use `setAxes` instead", RuntimeWarning) + + warnings.warn( + "`setAxis` has been removed in Qwt6: please use `setAxes` instead", + RuntimeWarning, + ) self.setAxes(xAxis, yAxis) - + def setXAxis(self, axis): + """ + Set the X axis + + The item will painted according to the coordinates its Axes. + + :param int axis: X Axis (`QwtPlot.xBottom` or `QwtPlot.xTop`) + + .. seealso:: + + :py:meth:`setAxes()`, :py:meth:`setYAxis()`, + :py:meth:`xAxis()`, :py:meth:`yAxis()` + """ if axis in (QwtPlot.xBottom, QwtPlot.xTop): self.__data.xAxis = axis self.itemChanged() - + def setYAxis(self, axis): + """ + Set the Y axis + + The item will painted according to the coordinates its Axes. + + :param int axis: Y Axis (`QwtPlot.yLeft` or `QwtPlot.yRight`) + + .. seealso:: + + :py:meth:`setAxes()`, :py:meth:`setXAxis()`, + :py:meth:`xAxis()`, :py:meth:`yAxis()` + """ if axis in (QwtPlot.yLeft, QwtPlot.yRight): self.__data.yAxis = axis self.itemChanged() def xAxis(self): + """ + :return: xAxis + """ return self.__data.xAxis - + def yAxis(self): + """ + :return: yAxis + """ return self.__data.yAxis - + def boundingRect(self): + """ + :return: An invalid bounding rect: QRectF(1.0, 1.0, -2.0, -2.0) + + .. note:: + + A width or height < 0.0 is ignored by the autoscaler + """ return QRectF(1.0, 1.0, -2.0, -2.0) - + def getCanvasMarginHint(self, xMap, yMap, canvasRect): - left = top = right = bottom = 0. + """ + Calculate a hint for the canvas margin + + When the QwtPlotItem::Margins flag is enabled the plot item + indicates, that it needs some margins at the borders of the canvas. + This is f.e. used by bar charts to reserve space for displaying + the bars. + + The margins are in target device coordinates ( pixels on screen ) + + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas in painter coordinates + + .. seealso:: + + :py:meth:`QwtPlot.getCanvasMarginsHint()`, + :py:meth:`QwtPlot.updateCanvasMargins()`, + """ + left = top = right = bottom = 0.0 return left, top, right, bottom - + def legendData(self): + """ + Return all information, that is needed to represent + the item on the legend + + `QwtLegendData` is basically a list of QVariants that makes it + possible to overload and reimplement legendData() to + return almost any type of information, that is understood + by the receiver that acts as the legend. + + The default implementation returns one entry with + the title() of the item and the legendIcon(). + + :return: Data, that is needed to represent the item on the legend + + .. seealso:: + + :py:meth:`title()`, :py:meth:`legendIcon()`, + :py:class:`qwt.legend.QwtLegend` + """ data = QwtLegendData() label = self.title() label.setRenderFlags(label.renderFlags() & Qt.AlignLeft) @@ -1064,12 +2249,44 @@ def legendData(self): if not graphic.isNull(): data.setValue(QwtLegendData.IconRole, graphic) return [data] - + def updateLegend(self, item, data): + """ + Update the item to changes of the legend info + + Plot items that want to display a legend ( not those, that want to + be displayed on a legend ! ) will have to implement updateLegend(). + + updateLegend() is only called when the LegendInterest interest + is enabled. The default implementation does nothing. + + :param qwt.plot.QwtPlotItem item: Plot item to be displayed on a legend + :param list data: Attributes how to display item on the legend + + .. note:: + + Plot items, that want to be displayed on a legend + need to enable the `QwtPlotItem.Legend` flag and to implement + legendData() and legendIcon() + """ pass def scaleRect(self, xMap, yMap): + """ + Calculate the bounding scale rectangle of 2 maps + + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :return: Bounding scale rect of the scale maps, not normalized + """ return QRectF(xMap.s1(), yMap.s1(), xMap.sDist(), yMap.sDist()) - + def paintRect(self, xMap, yMap): + """ + Calculate the bounding paint rectangle of 2 maps + + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :return: Bounding paint rectangle of the scale maps, not normalized + """ return QRectF(xMap.p1(), yMap.p1(), xMap.pDist(), yMap.pDist()) diff --git a/qwt/plot_canvas.py b/qwt/plot_canvas.py index 1845377..a72d9b8 100644 --- a/qwt/plot_canvas.py +++ b/qwt/plot_canvas.py @@ -5,15 +5,35 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) +""" +QwtPlotCanvas +------------- + +.. autoclass:: QwtPlotCanvas + :members: +""" + +from collections.abc import Sequence + +from qtpy.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt +from qtpy.QtGui import ( + QBrush, + QGradient, + QImage, + QPaintEngine, + QPainter, + QPainterPath, + QPen, + QPixmap, + QPolygonF, + QRegion, + qAlpha, +) +from qtpy.QtWidgets import QFrame, QStyle, QStyleOption, QStyleOptionFrame + from qwt.null_paintdevice import QwtNullPaintDevice from qwt.painter import QwtPainter -from qwt.qt import PYQT5 -from qwt.qt.QtGui import (QFrame, QPaintEngine, QPen, QBrush, QRegion, QImage, - QPainterPath, QPixmap, QGradient, QPainter, qAlpha, - QPolygonF, QStyleOption, QStyle, QStyleOptionFrame) -from qwt.qt.QtCore import Qt, QSizeF, QT_VERSION, QEvent, QPointF, QRectF - class Border(object): def __init__(self): @@ -39,7 +59,7 @@ def __init__(self, size): self.clipRects = [] self.border = Border() self.background = Background() - + def updateState(self, state): if state.state() & QPaintEngine.DirtyPen: self.__pen = state.pen() @@ -47,13 +67,20 @@ def updateState(self, state): self.__brush = state.brush() if state.state() & QPaintEngine.DirtyBrushOrigin: self.__origin = state.brushOrigin() - + def drawRects(self, rects, count): - for i in range(count): - self.border.rectList += rects[i] - + if isinstance(rects, (QRect, QRectF)): + self.border.rectList = [QRectF(rects)] + elif isinstance(rects, Sequence): + self.border.rectList.extend(QRectF(rects[i]) for i in range(count)) + else: + raise TypeError( + "drawRects() expects a QRect, QRectF or a sequence of them, " + f"but got {type(rects).__name__}" + ) + def drawPath(self, path): - rect = QRectF(QPointF(0., 0.), self.__size) + rect = QRectF(QPointF(0.0, 0.0), self.__size) if path.controlPointRect().contains(rect.center()): self.setCornerRects(path) self.alignCornerRects(rect) @@ -62,13 +89,12 @@ def drawPath(self, path): self.background.origin = self.__origin else: self.border.pathlist += [path] - + def setCornerRects(self, path): - pos = QPointF(0., 0.) + pos = QPointF(0.0, 0.0) for i in range(path.elementCount()): el = path.elementAt(i) - if el.type in (QPainterPath.MoveToElement, - QPainterPath.LineToElement): + if el.type in (QPainterPath.MoveToElement, QPainterPath.LineToElement): pos.setX(el.x) pos.setY(el.y) elif el.type == QPainterPath.CurveToElement: @@ -79,15 +105,17 @@ def setCornerRects(self, path): elif el.type == QPainterPath.CurveToDataElement: if self.clipRects: r = self.clipRects[-1] - r.setCoords(min([r.left(), el.x]), - min([r.top(), el.y]), - max([r.right(), el.x]), - max([r.bottom(), el.y])) + r.setCoords( + min([r.left(), el.x]), + min([r.top(), el.y]), + max([r.right(), el.x]), + max([r.bottom(), el.y]), + ) self.clipRects[-1] = r.normalized() - + def sizeMetrics(self): return self.__size - + def alignCornerRects(self, rect): for r in self.clipRects: if r.center().x() < rect.center().x(): @@ -100,14 +128,6 @@ def alignCornerRects(self, rect): r.setBottom(rect.bottom()) -def _rects_conv_PyQt5(rects): - # PyQt5 compatibility: the conversion from QRect to QRectF should not - # be necessary but it seems to be anyway... PyQt5 bug? - if PYQT5: - return [QRectF(rect) for rect in rects] - else: - return rects - def qwtDrawBackground(painter, canvas): painter.save() borderClip = canvas.borderPath(canvas.rect()) @@ -123,7 +143,7 @@ def qwtDrawBackground(painter, canvas): if brush.gradient().coordinateMode() == QGradient.ObjectBoundingMode: rects += [canvas.rect()] else: - rects += [painter.clipRegion().rects()] + rects += [painter.clipRegion().boundingRect()] useRaster = False if painter.paintEngine().type() == QPaintEngine.X11: useRaster = True @@ -135,20 +155,22 @@ def qwtDrawBackground(painter, canvas): format_ = QImage.Format_ARGB32 break image = QImage(canvas.size(), format_) - p = QPainter(image) - p.setPen(Qt.NoPen) - p.setBrush(brush) - p.drawRects(_rects_conv_PyQt5(rects)) - p.end() + pntr = QPainter(image) + pntr.setPen(Qt.NoPen) + pntr.setBrush(brush) + for rect in rects: + pntr.drawRect(rect) + pntr.end() painter.drawImage(0, 0, image) else: painter.setPen(Qt.NoPen) painter.setBrush(brush) - painter.drawRects(_rects_conv_PyQt5(rects)) + for rect in rects: + painter.drawRect(rect) else: painter.setPen(Qt.NoPen) painter.setBrush(brush) - painter.drawRects(_rects_conv_PyQt5(painter.clipRegion().rects())) + painter.drawRect(painter.clipRegion().boundingRect()) painter.restore() @@ -164,19 +186,19 @@ def qwtRevertPath(path): def qwtCombinePathList(rect, pathList): if not pathList: return QPainterPath() - + ordered = [None] * 8 for subPath in pathList: index = -1 br = subPath.controlPointRect() if br.center().x() < rect.center().x(): if br.center().y() < rect.center().y(): - if abs(br.top()-rect.top()) < abs(br.left()-rect.left()): + if abs(br.top() - rect.top()) < abs(br.left() - rect.left()): index = 1 else: index = 0 else: - if abs(br.bottom()-rect.bottom) < abs(br.left()-rect.left()): + if abs(br.bottom() - rect.bottom) < abs(br.left() - rect.left()): index = 6 else: index = 7 @@ -184,12 +206,12 @@ def qwtCombinePathList(rect, pathList): qwtRevertPath(subPath) else: if br.center().y() < rect.center().y(): - if abs(br.top()-rect.top()) < abs(br.right()-rect.right()): + if abs(br.top() - rect.top()) < abs(br.right() - rect.right()): index = 2 else: index = 3 else: - if abs(br.bottom()-rect.bottom()) < abs(br.right()-rect.right()): + if abs(br.bottom() - rect.bottom()) < abs(br.right() - rect.right()): index = 5 else: index = 4 @@ -197,16 +219,16 @@ def qwtCombinePathList(rect, pathList): qwtRevertPath(subPath) ordered[index] = subPath for i in range(4): - if ordered[2*i].isEmpty() != ordered[2*i+1].isEmpty(): + if ordered[2 * i].isEmpty() != ordered[2 * i + 1].isEmpty(): return QPainterPath() corners = QPolygonF(rect) path = QPainterPath() for i in range(4): - if ordered[2*i].isEmpty(): + if ordered[2 * i].isEmpty(): path.lineTo(corners[i]) else: - path.connectPath(ordered[2*i]) - path.connectPath(ordered[2*i+1]) + path.connectPath(ordered[2 * i]) + path.connectPath(ordered[2 * i + 1]) path.closeSubpath() return path @@ -253,18 +275,20 @@ def qwtFillBackground(*args): else: r = canvas.rect() radius = canvas.borderRadius() - if radius > 0.: - sz = QSizeF(radius, radius) - rects += [QRectF(r.topLeft(), sz), - QRectF(r.topRight()-QPointF(radius, 0), sz), - QRectF(r.bottomRight()-QPointF(radius, radius), sz), - QRectF(r.bottomLeft()-QPointF(0, radius), sz)] + if radius > 0.0: + sz = QSize(radius, radius) + rects += [ + QRect(r.topLeft(), sz), + QRect(r.topRight() - QPoint(radius, 0), sz), + QRect(r.bottomRight() - QPoint(radius, radius), sz), + QRect(r.bottomLeft() - QPoint(0, radius), sz), + ] qwtFillBackground(painter, canvas, rects) elif len(args) == 3: painter, widget, fillRects = args - + if not fillRects: return if painter.hasClipping(): @@ -272,16 +296,19 @@ def qwtFillBackground(*args): else: clipRegion = widget.contentsRect() bgWidget = qwtBackgroundWidget(widget.parentWidget()) - for fillRect in fillRects: - rect = fillRect.toAlignedRect() + for rect in fillRects: if clipRegion.intersects(rect): pm = QPixmap(rect.size()) - QwtPainter.fillPixmap(bgWidget, pm, widget.mapTo(bgWidget, rect.topLeft())) + QwtPainter.fillPixmap( + bgWidget, pm, widget.mapTo(bgWidget, rect.topLeft()) + ) painter.drawPixmap(rect, pm) - + else: - raise TypeError("%s() takes 2 or 3 argument(s) (%s given)"\ - % ("qwtFillBackground", len(args))) + raise TypeError( + "%s() takes 2 or 3 argument(s) (%s given)" + % ("qwtFillBackground", len(args)) + ) class StyleSheetBackground(object): @@ -289,15 +316,19 @@ def __init__(self): self.brush = QBrush() self.origin = QPointF() + class StyleSheet(object): def __init__(self): self.hasBorder = False self.borderPath = QPainterPath() self.cornerRects = [] self.background = StyleSheetBackground() - -class QwtPlotCanvas_PrivateData(object): + + +class QwtPlotCanvas_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.focusIndicator = QwtPlotCanvas.NoFocusIndicator self.borderRadius = 0 self.paintAttributes = 0 @@ -307,20 +338,112 @@ def __init__(self): class QwtPlotCanvas(QFrame): - + """ + Canvas of a QwtPlot. + + Canvas is the widget where all plot items are displayed + + .. seealso:: + + :py:meth:`qwt.plot.QwtPlot.setCanvas()` + + Paint attributes: + + * `QwtPlotCanvas.BackingStore`: + + Paint double buffered reusing the content of the pixmap buffer + when possible. + + Using a backing store might improve the performance significantly, + when working with widget overlays (like rubber bands). + Disabling the cache might improve the performance for + incremental paints + (using :py:class:`qwt.plot_directpainter.QwtPlotDirectPainter`). + + * `QwtPlotCanvas.Opaque`: + + Try to fill the complete contents rectangle of the plot canvas + + When using styled backgrounds Qt assumes, that the canvas doesn't + fill its area completely (f.e because of rounded borders) and + fills the area below the canvas. When this is done with gradients + it might result in a serious performance bottleneck - depending on + the size. + + When the Opaque attribute is enabled the canvas tries to + identify the gaps with some heuristics and to fill those only. + + .. warning:: + + Will not work for semitransparent backgrounds + + * `QwtPlotCanvas.HackStyledBackground`: + + Try to improve painting of styled backgrounds + + `QwtPlotCanvas` supports the box model attributes for + customizing the layout with style sheets. Unfortunately + the design of Qt style sheets has no concept how to + handle backgrounds with rounded corners - beside of padding. + + When HackStyledBackground is enabled the plot canvas tries + to separate the background from the background border + by reverse engineering to paint the background before and + the border after the plot items. In this order the border + gets perfectly antialiased and you can avoid some pixel + artifacts in the corners. + + * `QwtPlotCanvas.ImmediatePaint`: + + When ImmediatePaint is set replot() calls repaint() + instead of update(). + + .. seealso:: + + :py:meth:`replot()`, :py:meth:`QWidget.repaint()`, + :py:meth:`QWidget.update()` + + Focus indicators: + + * `QwtPlotCanvas.NoFocusIndicator`: + + Don't paint a focus indicator + + * `QwtPlotCanvas.CanvasFocusIndicator`: + + The focus is related to the complete canvas. + Paint the focus indicator using paintFocus() + + * `QwtPlotCanvas.ItemFocusIndicator`: + + The focus is related to an item (curve, point, ...) on + the canvas. It is up to the application to display a + focus indication using f.e. highlighting. + + .. py:class:: QwtPlotCanvas([plot=None]) + + Constructor + + :param qwt.plot.QwtPlot plot: Parent plot widget + + .. seealso:: + + :py:meth:`qwt.plot.QwtPlot.setCanvas()` + """ + # enum PaintAttribute BackingStore = 1 Opaque = 2 HackStyledBackground = 4 ImmediatePaint = 8 - + # enum FocusIndicator NoFocusIndicator, CanvasFocusIndicator, ItemFocusIndicator = list(range(3)) - + def __init__(self, plot=None): super(QwtPlotCanvas, self).__init__(plot) self.__plot = plot - self.setFrameStyle(QFrame.Panel|QFrame.Sunken) + self.setFrameStyle(QFrame.Panel | QFrame.Sunken) self.setLineWidth(2) self.__data = QwtPlotCanvas_PrivateData() self.setCursor(Qt.CrossCursor) @@ -328,11 +451,31 @@ def __init__(self, plot=None): self.setPaintAttribute(QwtPlotCanvas.BackingStore, False) self.setPaintAttribute(QwtPlotCanvas.Opaque, True) self.setPaintAttribute(QwtPlotCanvas.HackStyledBackground, True) - + def plot(self): + """ + :return: Parent plot widget + """ return self.__plot - + def setPaintAttribute(self, attribute, on=True): + """ + Changing the paint attributes + + Paint attributes: + + * `QwtPlotCanvas.BackingStore` + * `QwtPlotCanvas.Opaque` + * `QwtPlotCanvas.HackStyledBackground` + * `QwtPlotCanvas.ImmediatePaint` + + :param int attribute: Paint attribute + :param bool on: On/Off + + .. seealso:: + + :py:meth:`testPaintAttribute()`, :py:meth:`backingStore()` + """ if bool(self.__data.paintAttributes & attribute) == on: return if on: @@ -344,14 +487,7 @@ def setPaintAttribute(self, attribute, on=True): if self.__data.backingStore is None: self.__data.backingStore = QPixmap() if self.isVisible(): - if QT_VERSION >= 0x050000: - self.__data.backingStore = self.grab(self.rect()) - else: - if PYQT5: - pm = QPixmap.grabWidget(self, self.rect()) - else: - pm = self.grab(self.rect()) - self.__data.backingStore = pm + self.__data.backingStore = self.grab(self.rect()) else: self.__data.backingStore = None elif attribute == self.Opaque: @@ -359,29 +495,81 @@ def setPaintAttribute(self, attribute, on=True): self.setAttribute(Qt.WA_OpaquePaintEvent, True) elif attribute in (self.HackStyledBackground, self.ImmediatePaint): pass - + def testPaintAttribute(self, attribute): + """ + Test whether a paint attribute is enabled + + :param int attribute: Paint attribute + :return: True, when attribute is enabled + + .. seealso:: + + :py:meth:`setPaintAttribute()` + """ return self.__data.paintAttributes & attribute - + def backingStore(self): + """ + :return: Backing store, might be None + """ return self.__data.backingStore - + def invalidateBackingStore(self): + """Invalidate the internal backing store""" if self.__data.backingStore: self.__data.backingStore = QPixmap() - + def setFocusIndicator(self, focusIndicator): + """ + Set the focus indicator + + Focus indicators: + + * `QwtPlotCanvas.NoFocusIndicator` + * `QwtPlotCanvas.CanvasFocusIndicator` + * `QwtPlotCanvas.ItemFocusIndicator` + + :param int focusIndicator: Focus indicator + + .. seealso:: + + :py:meth:`focusIndicator()` + """ self.__data.focusIndicator = focusIndicator - + def focusIndicator(self): + """ + :return: Focus indicator + + .. seealso:: + + :py:meth:`setFocusIndicator()` + """ return self.__data.focusIndicator - + def setBorderRadius(self, radius): - self.__data.borderRadius = max([0., radius]) - + """ + Set the radius for the corners of the border frame + + :param float radius: Radius of a rounded corner + + .. seealso:: + + :py:meth:`borderRadius()` + """ + self.__data.borderRadius = max([0.0, radius]) + def borderRadius(self): + """ + :return: Radius for the corners of the border frame + + .. seealso:: + + :py:meth:`setBorderRadius()` + """ return self.__data.borderRadius - + def event(self, event): if event.type() == QEvent.PolishRequest: if self.testPaintAttribute(self.Opaque): @@ -389,14 +577,18 @@ def event(self, event): if event.type() in (QEvent.PolishRequest, QEvent.StyleChange): self.updateStyleSheetInfo() return QFrame.event(self, event) - + def paintEvent(self, event): painter = QPainter(self) painter.setClipRegion(event.region()) - if self.testPaintAttribute(self.BackingStore) and\ - self.__data.backingStore is not None: + if ( + self.testPaintAttribute(self.BackingStore) + and self.__data.backingStore is not None + and not self.__data.backingStore.isNull() + ): bs = self.__data.backingStore - if bs.size() != self.size(): + pixelRatio = bs.devicePixelRatio() + if bs.size() != self.size() * pixelRatio: bs = QwtPainter.backingStore(self, self.size()) if self.testAttribute(Qt.WA_StyledBackground): p = QPainter(bs) @@ -404,8 +596,8 @@ def paintEvent(self, event): self.drawCanvas(p, True) else: p = QPainter() - if self.__data.borderRadius <= 0.: -# print('**DEBUG: QwtPlotCanvas.paintEvent') + if self.__data.borderRadius <= 0.0: + # print('**DEBUG: QwtPlotCanvas.paintEvent') QwtPainter.fillPixmap(self, bs) p.begin(bs) self.drawCanvas(p, False) @@ -430,7 +622,7 @@ def paintEvent(self, event): qwtFillBackground(painter, self) qwtDrawBackground(painter, self) else: - if self.borderRadius() > 0.: + if self.borderRadius() > 0.0: clipPath = QPainterPath() clipPath.addRect(self.rect()) clipPath = clipPath.subtracted(self.borderPath(self.rect())) @@ -444,18 +636,36 @@ def paintEvent(self, event): self.drawBorder(painter) if self.hasFocus() and self.focusIndicator() == self.CanvasFocusIndicator: self.drawFocusIndicator(painter) - + def drawCanvas(self, painter, withBackground): hackStyledBackground = False - if withBackground and self.testAttribute(Qt.WA_StyledBackground) and\ - self.testPaintAttribute(self.HackStyledBackground): - if self.__data.styleSheet.hasBorder and\ - not self.__data.styleSheet.borderPath.isEmpty(): + if ( + withBackground + and self.testAttribute(Qt.WA_StyledBackground) + and self.testPaintAttribute(self.HackStyledBackground) + ): + # Antialiasing rounded borders is done by + # inserting pixels with colors between the + # border color and the color on the canvas, + # When the border is painted before the plot items + # these colors are interpolated for the canvas + # and the plot items need to be clipped excluding + # the anialiased pixels. In situations, where + # the plot items fill the area at the rounded + # borders this is noticeable. + # The only way to avoid these annoying "artefacts" + # is to paint the border on top of the plot items. + if ( + self.__data.styleSheet.hasBorder + and not self.__data.styleSheet.borderPath.isEmpty() + ): + # We have a border with at least one rounded corner hackStyledBackground = True if withBackground: painter.save() if self.testAttribute(Qt.WA_StyledBackground): if hackStyledBackground: + # paint background without border painter.setPen(Qt.NoPen) painter.setBrush(self.__data.styleSheet.background.brush) painter.setBrushOrigin(self.__data.styleSheet.background.origin) @@ -466,7 +676,7 @@ def drawCanvas(self, painter, withBackground): elif self.autoFillBackground(): painter.setPen(Qt.NoPen) painter.setBrush(self.palette().brush(self.backgroundRole())) - if self.__data.borderRadius > 0. and self.rect() == self.frameRect(): + if self.__data.borderRadius > 0.0 and self.rect() == self.frameRect(): if self.frameWidth() > 0: painter.setClipPath(self.borderPath(self.rect())) painter.drawRect(self.rect()) @@ -478,79 +688,114 @@ def drawCanvas(self, painter, withBackground): painter.restore() painter.save() if not self.__data.styleSheet.borderPath.isEmpty(): - painter.setClipPath(self.__data.styleSheet.borderPath, - Qt.IntersectClip) + painter.setClipPath(self.__data.styleSheet.borderPath, Qt.IntersectClip) else: - if self.__data.borderRadius > 0.: - painter.setClipPath(self.borderPath(self.frameRect()), - Qt.IntersectClip) + if self.__data.borderRadius > 0.0: + painter.setClipPath(self.borderPath(self.frameRect()), Qt.IntersectClip) else: -# print('**DEBUG: QwtPlotCanvas.drawCanvas') + # print('**DEBUG: QwtPlotCanvas.drawCanvas') painter.setClipRect(self.contentsRect(), Qt.IntersectClip) self.plot().drawCanvas(painter) painter.restore() if withBackground and hackStyledBackground: + # Now paint the border on top opt = QStyleOptionFrame() opt.initFrom(self) self.style().drawPrimitive(QStyle.PE_Frame, opt, painter, self) - + def drawBorder(self, painter): + """ + Draw the border of the plot canvas + + :param QPainter painter: Painter + + .. seealso:: + + :py:meth:`setBorderRadius()` + """ if self.__data.borderRadius > 0: if self.frameWidth() > 0: - QwtPainter.drawRoundedFrame(painter, QRectF(self.frameRect()), - self.__data.borderRadius, self.__data.borderRadius, - self.palette(), self.frameWidth(), self.frameStyle()) + QwtPainter.drawRoundedFrame( + painter, + QRectF(self.frameRect()), + self.__data.borderRadius, + self.__data.borderRadius, + self.palette(), + self.frameWidth(), + self.frameStyle(), + ) else: - if QT_VERSION >= 0x040500: - if PYQT5: - from qwt.qt.QtGui import QStyleOptionFrame - else: - from qwt.qt.QtGui import QStyleOptionFrameV3 as\ - QStyleOptionFrame - opt = QStyleOptionFrame() - opt.initFrom(self) - frameShape = self.frameStyle() & QFrame.Shape_Mask - frameShadow = self.frameStyle() & QFrame.Shadow_Mask - opt.frameShape = QFrame.Shape(int(opt.frameShape)|frameShape) - if frameShape in (QFrame.Box, QFrame.HLine, QFrame.VLine, - QFrame.StyledPanel, QFrame.Panel): - opt.lineWidth = self.lineWidth() - opt.midLineWidth = self.midLineWidth() - else: - opt.lineWidth = self.frameWidth() - if frameShadow == self.Sunken: - opt.state |= QStyle.State_Sunken - elif frameShadow == self.Raised: - opt.state |= QStyle.State_Raised - self.style().drawControl(QStyle.CE_ShapedFrame, opt, painter, self) + opt = QStyleOptionFrame() + opt.initFrom(self) + try: + shape_mask = QFrame.Shape_Mask.value + shadow_mask = QFrame.Shadow_Mask.value + except AttributeError: + shape_mask = QFrame.Shape_Mask + shadow_mask = QFrame.Shadow_Mask + frameShape = self.frameStyle() & shape_mask + frameShadow = self.frameStyle() & shadow_mask + opt.frameShape = QFrame.Shape(int(opt.frameShape) | frameShape) + if frameShape in ( + QFrame.Box, + QFrame.HLine, + QFrame.VLine, + QFrame.StyledPanel, + QFrame.Panel, + ): + opt.lineWidth = self.lineWidth() + opt.midLineWidth = self.midLineWidth() else: - self.drawFrame(painter) - + opt.lineWidth = self.frameWidth() + if frameShadow == QFrame.Sunken: + opt.state |= QStyle.State_Sunken + elif frameShadow == QFrame.Raised: + opt.state |= QStyle.State_Raised + self.style().drawControl(QStyle.CE_ShapedFrame, opt, painter, self) + def resizeEvent(self, event): QFrame.resizeEvent(self, event) self.updateStyleSheetInfo() - + def drawFocusIndicator(self, painter): + """ + Draw the focus indication + + :param QPainter painter: Painter + """ margin = 1 focusRect = self.contentsRect() - focusRect.setRect(focusRect.x()+margin, focusRect.y()+margin, - focusRect.width()-2*margin, focusRect.height()-2*margin) + focusRect.setRect( + focusRect.x() + margin, + focusRect.y() + margin, + focusRect.width() - 2 * margin, + focusRect.height() - 2 * margin, + ) QwtPainter.drawFocusRect(painter, self, focusRect) - + def replot(self): + """ + Invalidate the paint cache and repaint the canvas + """ self.invalidateBackingStore() if self.testPaintAttribute(self.ImmediatePaint): self.repaint(self.contentsRect()) else: self.update(self.contentsRect()) - + def invalidatePaintCache(self): import warnings - warnings.warn("`invalidatePaintCache` has been removed: "\ - "please use `replot` instead", RuntimeWarning) + + warnings.warn( + "`invalidatePaintCache` has been removed: please use `replot` instead", + RuntimeWarning, + ) self.replot() def updateStyleSheetInfo(self): + """ + Update the cached information about the current style sheet + """ if not self.testAttribute(Qt.WA_StyledBackground): return recorder = QwtStyleSheetRecorder(self.size()) @@ -559,18 +804,28 @@ def updateStyleSheetInfo(self): opt.initFrom(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) painter.end() - self.__data.styleSheet.hasBorder = not recorder.border.rectList.isEmpty() + self.__data.styleSheet.hasBorder = len(recorder.border.rectList) > 0 self.__data.styleSheet.cornerRects = recorder.clipRects if recorder.background.path.isEmpty(): - if not recorder.border.rectList.isEmpty(): - self.__data.styleSheet.borderPath =\ - qwtCombinePathList(self.rect(), recorder.border.pathlist) + if self.__data.styleSheet.hasBorder: + self.__data.styleSheet.borderPath = qwtCombinePathList( + self.rect(), recorder.border.pathlist + ) else: self.__data.styleSheet.borderPath = recorder.background.path self.__data.styleSheet.background.brush = recorder.background.brush self.__data.styleSheet.background.origin = recorder.background.origin - + def borderPath(self, rect): + """ + Calculate the painter path for a styled or rounded border + + When the canvas has no styled background or rounded borders + the painter path is empty. + + :param QRect rect: Bounding rectangle of the canvas + :return: Painter path, that can be used for clipping + """ if self.testAttribute(Qt.WA_StyledBackground): recorder = QwtStyleSheetRecorder(rect.size()) painter = QPainter(recorder) @@ -581,13 +836,12 @@ def borderPath(self, rect): painter.end() if not recorder.background.path.isEmpty(): return recorder.background.path - if not recorder.border.rectList.isEmpty(): + if len(recorder.border.rectList) > 0: return qwtCombinePathList(rect, recorder.border.pathlist) - elif self.__data.borderRadius > 0.: - fw2 = self.frameWidth()*.5 + elif self.__data.borderRadius > 0.0: + fw2 = self.frameWidth() * 0.5 r = QRectF(rect).adjusted(fw2, fw2, -fw2, -fw2) path = QPainterPath() - path.addRoundedRect(r, self.__data.borderRadius, - self.__data.borderRadius) + path.addRoundedRect(r, self.__data.borderRadius, self.__data.borderRadius) return path return QPainterPath() diff --git a/qwt/plot_curve.py b/qwt/plot_curve.py index 99e1b0b..6b4105e 100644 --- a/qwt/plot_curve.py +++ b/qwt/plot_curve.py @@ -5,89 +5,217 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.curve_fitter import QwtSplineCurveFitter -from qwt.text import QwtText -from qwt.plot import QwtPlotItem, QwtPlotItem_PrivateData -from qwt.painter import QwtPainter -from qwt.point_mapper import QwtPointMapper -from qwt.clipper import QwtClipper -from qwt.math import qwtSqr +""" +QwtPlotCurve +------------ + +.. autoclass:: QwtPlotCurve + :members: +""" + +import math +import os + +import numpy as np +from qtpy.QtCore import QLineF, QPointF, QRectF, QSize, Qt +from qtpy.QtGui import QBrush, QColor, QPainter, QPen, QPolygonF + +from qwt._math import qwtSqr from qwt.graphic import QwtGraphic -from qwt.series_data import QwtPointSeriesData, QwtSeriesData -from qwt.series_store import QwtSeriesStore -from qwt.plot_seriesitem import QwtPlotSeriesItem -from qwt.point_data import QwtPointArrayData, QwtCPointerData -from qwt.symbol import QwtSymbol +from qwt.plot import QwtPlot, QwtPlotItem, QwtPlotItem_PrivateData from qwt.plot_directpainter import QwtPlotDirectPainter +from qwt.plot_series import ( + QwtPlotSeriesItem, + QwtPointArrayData, + QwtSeriesData, + QwtSeriesStore, +) +from qwt.qthelpers import qcolor_from_str +from qwt.symbol import QwtSymbol +from qwt.text import QwtText -from qwt.qt.QtGui import (QPen, QBrush, QPaintEngine, QPainter, QPolygonF, - QColor) -from qwt.qt.QtCore import QSize, Qt, QT_VERSION, QRectF, QPointF +QT_API = os.environ["QT_API"] -import numpy as np +if QT_API == "pyside6": + import ctypes + + import shiboken6 as shiboken def qwtUpdateLegendIconSize(curve): - if curve.symbol() and\ - curve.testLegendAttribute(QwtPlotCurve.LegendShowSymbol): + if curve.symbol() and curve.testLegendAttribute(QwtPlotCurve.LegendShowSymbol): sz = curve.symbol().boundingRect().size() sz += QSize(2, 2) if curve.testLegendAttribute(QwtPlotCurve.LegendShowLine): - w = np.ceil(1.5*sz.width()) + w = math.ceil(1.5 * sz.width()) if w % 2: w += 1 sz.setWidth(max([8, w])) curve.setLegendIconSize(sz) + def qwtVerifyRange(size, i1, i2): if size < 1: return 0 - i1 = max([0, min([i1, size-1])]) - i2 = max([0, min([i2, size-1])]) + i1 = max([0, min([i1, size - 1])]) + i2 = max([0, min([i2, size - 1])]) if i1 > i2: i1, i2 = i2, i1 - return i2-i1+1 + return i2 - i1 + 1 + + +def array2d_to_qpolygonf(xdata, ydata): + """ + Utility function to convert two 1D-NumPy arrays representing curve data + (X-axis, Y-axis data) into a single polyline (QtGui.PolygonF object). + This feature is compatible with PyQt5 and PySide6 (requires QtPy). + + License/copyright: MIT License © Pierre Raybaut 2020-2021. + + :param numpy.ndarray xdata: 1D-NumPy array + :param numpy.ndarray ydata: 1D-NumPy array + :return: Polyline + :rtype: QtGui.QPolygonF + """ + if not (xdata.size == ydata.size == xdata.shape[0] == ydata.shape[0]): + raise ValueError("Arguments must be 1D NumPy arrays with same size") + size = xdata.size + if QT_API.startswith("pyside"): # PySide (obviously...) + polyline = QPolygonF() + polyline.resize(size) + address = shiboken.getCppPointer(polyline.data())[0] + buffer = (ctypes.c_double * 2 * size).from_address(address) + else: # PyQt + if QT_API == "pyqt6": + polyline = QPolygonF([QPointF(0, 0)] * size) + else: + polyline = QPolygonF(size) + buffer = polyline.data() + buffer.setsize(16 * size) # 16 bytes per point: 8 bytes per X,Y value (float64) + memory = np.frombuffer(buffer, np.float64) + memory[: (size - 1) * 2 + 1 : 2] = np.asarray(xdata, dtype=np.float64) + memory[1 : (size - 1) * 2 + 2 : 2] = np.asarray(ydata, dtype=np.float64) + return polyline + + +def series_to_polyline(xMap, yMap, series, from_, to): + """ + Convert series data to QPolygon(F) polyline + """ + xdata = xMap.transform(series.xData()[from_ : to + 1]) + ydata = yMap.transform(series.yData()[from_ : to + 1]) + return array2d_to_qpolygonf(xdata, ydata) class QwtPlotCurve_PrivateData(QwtPlotItem_PrivateData): def __init__(self): QwtPlotItem_PrivateData.__init__(self) self.style = QwtPlotCurve.Lines - self.baseline = 0. + self.baseline = 0.0 self.symbol = None self.attributes = 0 - self.paintAttributes = QwtPlotCurve.FilterPoints - #TODO: uncomment next line when QwtClipper will be implemented -# self.paintAttributes = QwtPlotCurve.ClipPolygons|QwtPlotCurve.FilterPoints self.legendAttributes = QwtPlotCurve.LegendShowLine self.pen = QPen(Qt.black) self.brush = QBrush() - self.curveFitter = QwtSplineCurveFitter() - + class QwtPlotCurve(QwtPlotSeriesItem, QwtSeriesStore): - + """ + A plot item, that represents a series of points + + A curve is the representation of a series of points in the x-y plane. + It supports different display styles and symbols. + + .. seealso:: + + :py:class:`qwt.symbol.QwtSymbol()`, + :py:class:`qwt.scale_map.QwtScaleMap()` + + Curve styles: + + * `QwtPlotCurve.NoCurve`: + + Don't draw a curve. Note: This doesn't affect the symbols. + + * `QwtPlotCurve.Lines`: + + Connect the points with straight lines. + + * `QwtPlotCurve.Sticks`: + + Draw vertical or horizontal sticks ( depending on the + orientation() ) from a baseline which is defined by setBaseline(). + + * `QwtPlotCurve.Steps`: + + Connect the points with a step function. The step function + is drawn from the left to the right or vice versa, + depending on the QwtPlotCurve::Inverted attribute. + + * `QwtPlotCurve.Dots`: + + Draw dots at the locations of the data points. Note: + This is different from a dotted line (see setPen()), and faster + as a curve in QwtPlotCurve::NoStyle style and a symbol + painting a point. + + * `QwtPlotCurve.UserCurve`: + + Styles >= QwtPlotCurve.UserCurve are reserved for derived + classes of QwtPlotCurve that overload drawCurve() with + additional application specific curve types. + + Curve attributes: + + * `QwtPlotCurve.Inverted`: + + For `QwtPlotCurve.Steps` only. + Draws a step function from the right to the left. + + Legend attributes: + + * `QwtPlotCurve.LegendNoAttribute`: + + `QwtPlotCurve` tries to find a color representing the curve + and paints a rectangle with it. + + * `QwtPlotCurve.LegendShowLine`: + + If the style() is not `QwtPlotCurve.NoCurve` a line + is painted with the curve pen(). + + * `QwtPlotCurve.LegendShowSymbol`: + + If the curve has a valid symbol it is painted. + + * `QwtPlotCurve.LegendShowBrush`: + + If the curve has a brush a rectangle filled with the + curve brush() is painted. + + + .. py:class:: QwtPlotCurve([title=None]) + + Constructor + + :param title: Curve title + :type title: qwt.text.QwtText or str or None + """ + # enum CurveStyle NoCurve = -1 Lines, Sticks, Steps, Dots = list(range(4)) UserCurve = 100 - + # enum CurveAttribute Inverted = 0x01 - Fitted = 0x02 - + # enum LegendAttribute LegendNoAttribute = 0x00 LegendShowLine = 0x01 LegendShowSymbol = 0x02 LegendShowBrush = 0x04 - - # enum PaintAttribute - ClipPolygons = 0x01 - FilterPoints = 0x02 - MinimizeMemory = 0x04 - ImageBuffer = 0x08 - + def __init__(self, title=None): if title is None: title = QwtText("") @@ -97,27 +225,115 @@ def __init__(self, title=None): QwtPlotSeriesItem.__init__(self, title) QwtSeriesStore.__init__(self) self.init() - + + @classmethod + def make( + cls, + xdata=None, + ydata=None, + title=None, + plot=None, + z=None, + x_axis=None, + y_axis=None, + style=None, + symbol=None, + linecolor=None, + linewidth=None, + linestyle=None, + antialiased=False, + size=None, + finite=None, + ): + """ + Create and setup a new `QwtPlotCurve` object (convenience function). + + :param xdata: List/array of x values + :param ydata: List/array of y values + :param title: Curve title + :type title: qwt.text.QwtText or str or None + :param plot: Plot to attach the curve to + :type plot: qwt.plot.QwtPlot or None + :param z: Z-value + :type z: float or None + :param x_axis: curve X-axis (default: QwtPlot.yLeft) + :type x_axis: int or None + :param y_axis: curve Y-axis (default: QwtPlot.xBottom) + :type y_axis: int or None + :param style: curve style (`QwtPlotCurve.NoCurve`, `QwtPlotCurve.Lines`, `QwtPlotCurve.Sticks`, `QwtPlotCurve.Steps`, `QwtPlotCurve.Dots`, `QwtPlotCurve.UserCurve`) + :type style: int or None + :param symbol: curve symbol + :type symbol: qwt.symbol.QwtSymbol or None + :param linecolor: curve line color + :type linecolor: QColor or str or None + :param linewidth: curve line width + :type linewidth: float or None + :param linestyle: curve pen style + :type linestyle: Qt.PenStyle or None + :param bool antialiased: if True, enable antialiasing rendering + :param size: size of xData and yData + :type size: int or None + :param bool finite: if True, keep only finite array elements (remove all infinity and not a number values), otherwise do not filter array elements + + .. seealso:: + + :py:meth:`setData()`, :py:meth:`setPen()`, :py:meth:`attach()` + """ + item = cls(title) + if z is not None: + item.setZ(z) + if xdata is not None or ydata is not None: + if xdata is None: + raise ValueError("Missing xdata parameter") + if ydata is None: + raise ValueError("Missing ydata parameter") + item.setData(xdata, ydata, size=size, finite=finite) + x_axis = QwtPlot.xBottom if x_axis is None else x_axis + y_axis = QwtPlot.yLeft if y_axis is None else y_axis + item.setAxes(x_axis, y_axis) + if style is not None: + item.setStyle(style) + if symbol is not None: + item.setSymbol(symbol) + linecolor = qcolor_from_str(linecolor, Qt.black) + linewidth = 1.0 if linewidth is None else linewidth + linestyle = Qt.SolidLine if linestyle is None else linestyle + item.setPen(QPen(linecolor, linewidth, linestyle)) + item.setRenderHint(cls.RenderAntialiased, antialiased) + if plot is not None: + item.attach(plot) + return item + def init(self): + """Initialize internal members""" self.__data = QwtPlotCurve_PrivateData() self.setItemAttribute(QwtPlotItem.Legend) self.setItemAttribute(QwtPlotItem.AutoScale) - self.setData(QwtPointSeriesData()) - self.setZ(20.) - + self.setData(QwtPointArrayData()) + self.setZ(20.0) + def rtti(self): + """:return: `QwtPlotItem.Rtti_PlotCurve`""" return QwtPlotItem.Rtti_PlotCurve - - def setPaintAttribute(self, attribute, on=True): - if on: - self.__data.paintAttributes |= attribute - else: - self.__data.paintAttributes &= ~attribute - - def testPaintAttribute(self, attribute): - return self.__data.paintAttributes & attribute - + def setLegendAttribute(self, attribute, on=True): + """ + Specify an attribute how to draw the legend icon + + Legend attributes: + + * `QwtPlotCurve.LegendNoAttribute` + * `QwtPlotCurve.LegendShowLine` + * `QwtPlotCurve.LegendShowSymbol` + * `QwtPlotCurve.LegendShowBrush` + + :param int attribute: Legend attribute + :param bool on: On/Off + + .. seealso:: + + :py:meth:`testLegendAttribute()`, :py:meth:`legendIcon()` + """ if on != self.testLegendAttribute(attribute): if on: self.__data.legendAttributes |= attribute @@ -125,37 +341,120 @@ def setLegendAttribute(self, attribute, on=True): self.__data.legendAttributes &= ~attribute qwtUpdateLegendIconSize(self) self.legendChanged() - + def testLegendAttribute(self, attribute): + """ + :param int attribute: Legend attribute + :return: True, when attribute is enabled + + .. seealso:: + + :py:meth:`setLegendAttribute()` + """ return self.__data.legendAttributes & attribute - + def setStyle(self, style): + """ + Set the curve's drawing style + + Valid curve styles: + + * `QwtPlotCurve.NoCurve` + * `QwtPlotCurve.Lines` + * `QwtPlotCurve.Sticks` + * `QwtPlotCurve.Steps` + * `QwtPlotCurve.Dots` + * `QwtPlotCurve.UserCurve` + + :param int style: Curve style + + .. seealso:: + + :py:meth:`style()` + """ if style != self.__data.style: self.__data.style = style self.legendChanged() self.itemChanged() - + def style(self): + """ + :return: Style of the curve + + .. seealso:: + + :py:meth:`setStyle()` + """ return self.__data.style - + def setSymbol(self, symbol): + """ + Assign a symbol + + The curve will take the ownership of the symbol, hence the previously + set symbol will be delete by setting a new one. If symbol is None no + symbol will be drawn. + + :param qwt.symbol.QwtSymbol symbol: Symbol + + .. seealso:: + + :py:meth:`symbol()` + """ if symbol != self.__data.symbol: self.__data.symbol = symbol qwtUpdateLegendIconSize(self) self.legendChanged() self.itemChanged() - + def symbol(self): + """ + :return: Current symbol or None, when no symbol has been assigned + + .. seealso:: + + :py:meth:`setSymbol()` + """ return self.__data.symbol - + def setPen(self, *args): + """ + Build and/or assign a pen, depending on the arguments. + + .. py:method:: setPen(color, width, style) + :noindex: + + Build and assign a pen + + In Qt5 the default pen width is 1.0 ( 0.0 in Qt4 ) what makes it + non cosmetic (see `QPen.isCosmetic()`). This method signature has + been introduced to hide this incompatibility. + + :param QColor color: Pen color + :param float width: Pen width + :param Qt.PenStyle style: Pen style + + .. py:method:: setPen(pen) + :noindex: + + Assign a pen + + :param QPen pen: New pen + + .. seealso:: + + :py:meth:`pen()`, :py:meth:`brush()` + """ if len(args) == 3: color, width, style = args + pen = QPen(color, width, style) elif len(args) == 1: - pen, = args + (pen,) = args else: - raise TypeError("%s().setPen() takes 1 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s().setPen() takes 1 or 3 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) if pen != self.__data.pen: if isinstance(pen, QColor): pen = QPen(pen) @@ -164,11 +463,37 @@ def setPen(self, *args): self.__data.pen = pen self.legendChanged() self.itemChanged() - + def pen(self): + """ + :return: Pen used to draw the lines + + .. seealso:: + + :py:meth:`setPen()`, :py:meth:`brush()` + """ return self.__data.pen - + def setBrush(self, brush): + """ + Assign a brush. + + In case of `brush.style() != QBrush.NoBrush` + and `style() != QwtPlotCurve.Sticks` + the area between the curve and the baseline will be filled. + + In case `not brush.color().isValid()` the area will be filled by + `pen.color()`. The fill algorithm simply connects the first and the + last curve point to the baseline. So the curve data has to be sorted + (ascending or descending). + + :param brush: New brush + :type brush: QBrush or QColor + + .. seealso:: + + :py:meth:`brush()`, :py:meth:`setBaseline()`, :py:meth:`baseline()` + """ if isinstance(brush, QColor): brush = QBrush(brush) else: @@ -177,45 +502,90 @@ def setBrush(self, brush): self.__data.brush = brush self.legendChanged() self.itemChanged() - + def brush(self): + """ + :return: Brush used to fill the area between lines and the baseline + + .. seealso:: + + :py:meth:`setBrush()`, :py:meth:`setBaseline()`, + :py:meth:`baseline()` + """ return self.__data.brush - + def directPaint(self, from_, to): - """When observing an measurement while it is running, new points have - to be added to an existing seriesItem. drawSeries() can be used to + """ + When observing a measurement while it is running, new points have + to be added to an existing seriesItem. This method can be used to display them avoiding a complete redraw of the canvas. - Setting plot().canvas().setAttribute(Qt.WA_PaintOutsidePaintEvent, True) - will result in faster painting, if the paint engine of the canvas widget - supports this feature.""" + Setting `plot().canvas().setAttribute(Qt.WA_PaintOutsidePaintEvent, True)` + will result in faster painting, if the paint engine of the canvas + widget supports this feature. + + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted + + .. seealso:: + + :py:meth:`drawSeries()` + """ directPainter = QwtPlotDirectPainter(self.plot()) directPainter.drawSeries(self, from_, to) - + def drawSeries(self, painter, xMap, yMap, canvasRect, from_, to): + """ + Draw an interval of the curve + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`drawCurve()`, :py:meth:`drawSymbols()` + """ numSamples = self.dataSize() if not painter or numSamples <= 0: return if to < 0: - to = numSamples-1 + to = numSamples - 1 if qwtVerifyRange(numSamples, from_, to) > 0: painter.save() painter.setPen(self.__data.pen) - self.drawCurve(painter, self.__data.style, xMap, yMap, canvasRect, - from_, to) + self.drawCurve( + painter, self.__data.style, xMap, yMap, canvasRect, from_, to + ) painter.restore() - if self.__data.symbol and\ - self.__data.symbol.style() != QwtSymbol.NoSymbol: + if self.__data.symbol and self.__data.symbol.style() != QwtSymbol.NoSymbol: painter.save() - self.drawSymbols(painter, self.__data.symbol, - xMap, yMap, canvasRect, from_, to) + self.drawSymbols( + painter, self.__data.symbol, xMap, yMap, canvasRect, from_, to + ) painter.restore() - + def drawCurve(self, painter, style, xMap, yMap, canvasRect, from_, to): + """ + Draw the line part (without symbols) of a curve interval. + + :param QPainter painter: Painter + :param int style: curve style, see `QwtPlotCurve.CurveStyle` + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`draw()`, :py:meth:`drawDots()`, :py:meth:`drawLines()`, + :py:meth:`drawSteps()`, :py:meth:`drawSticks()` + """ if style == self.Lines: - if self.testCurveAttribute(self.Fitted): - from_ = 0 - to = self.dataSize()-1 self.drawLines(painter, xMap, yMap, canvasRect, from_, to) elif style == self.Sticks: self.drawSticks(painter, xMap, yMap, canvasRect, from_, to) @@ -223,155 +593,151 @@ def drawCurve(self, painter, style, xMap, yMap, canvasRect, from_, to): self.drawSteps(painter, xMap, yMap, canvasRect, from_, to) elif style == self.Dots: self.drawDots(painter, xMap, yMap, canvasRect, from_, to) - + def drawLines(self, painter, xMap, yMap, canvasRect, from_, to): + """ + Draw lines + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`draw()`, :py:meth:`drawDots()`, + :py:meth:`drawSteps()`, :py:meth:`drawSticks()` + """ if from_ > to: return - doAlign = QwtPainter.roundingAlignment(painter) - doFit = (self.__data.attributes & self.Fitted)\ - and self.__data.curveFitter - doFill = self.__data.brush.style() != Qt.NoBrush\ - and self.__data.brush.color().alpha() > 0 - clipRect = QRectF() - if self.__data.paintAttributes & self.ClipPolygons: - pw = max([1., painter.pen().widthF()]) - clipRect = canvasRect.adjusted(-pw, -pw, pw, pw) - doIntegers = False - if QT_VERSION < 0x040800: - if painter.paintEngine().type() == QPaintEngine.Raster: - if not doFit and not doFill: - doIntegers = True - noDuplicates = self.__data.paintAttributes & self.FilterPoints - mapper = QwtPointMapper() - mapper.setFlag(QwtPointMapper.RoundPoints, doAlign) - mapper.setFlag(QwtPointMapper.WeedOutPoints, noDuplicates) - mapper.setBoundingRect(canvasRect) - if doIntegers: - polyline = mapper.toPolygon(xMap, yMap, self.data(), from_, to) - if self.__data.paintAttributes & self.ClipPolygons: - polyline = QwtClipper().clipPolygon(clipRect.toAlignedRect(), - polyline, False) - QwtPainter.drawPolyline(painter, polyline) - else: - polyline = mapper.toPolygonF(xMap, yMap, self.data(), from_, to) - if doFit: - polyline = self.__data.curveFitter.fitCurve(polyline) - if doFill: - if painter.pen().style() != Qt.NoPen: - filled = QPolygonF(polyline) - self.fillCurve(painter, xMap, yMap, canvasRect, filled) - filled.clear() - if self.__data.paintAttributes & self.ClipPolygons: - polyline = QwtClipper().clipPolygonF(clipRect, - polyline, False) - QwtPainter.drawPolyline(painter, polyline) - else: - self.fillCurve(painter, xMap, yMap, canvasRect, polyline) - else: - if self.__data.paintAttributes & self.ClipPolygons: - polyline = QwtClipper().clipPolygonF(clipRect, polyline, - False) - QwtPainter.drawPolyline(painter, polyline) - + doFill = ( + self.__data.brush.style() != Qt.NoBrush + and self.__data.brush.color().alpha() > 0 + ) + polyline = series_to_polyline(xMap, yMap, self.data(), from_, to) + painter.drawPolyline(polyline) + if doFill: + self.fillCurve(painter, xMap, yMap, canvasRect, polyline) + def drawSticks(self, painter, xMap, yMap, canvasRect, from_, to): + """ + Draw sticks + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`draw()`, :py:meth:`drawDots()`, + :py:meth:`drawSteps()`, :py:meth:`drawLines()` + """ painter.save() painter.setRenderHint(QPainter.Antialiasing, False) - doAlign = QwtPainter.roundingAlignment(painter) x0 = xMap.transform(self.__data.baseline) y0 = yMap.transform(self.__data.baseline) - if doAlign: - x0 = round(x0) - y0 = round(y0) o = self.orientation() series = self.data() - for i in range(from_, to+1): + for i in range(from_, to + 1): sample = series.sample(i) xi = xMap.transform(sample.x()) yi = yMap.transform(sample.y()) - if doAlign: - xi = round(xi) - yi = round(yi) if o == Qt.Horizontal: - QwtPainter.drawLine(painter, x0, yi, xi, yi) + painter.drawLine(QLineF(xi, y0, xi, yi)) else: - QwtPainter.drawLine(painter, xi, y0, xi, yi) + painter.drawLine(QLineF(x0, yi, xi, yi)) painter.restore() - + def drawDots(self, painter, xMap, yMap, canvasRect, from_, to): - color = painter.pen().color() - if painter.pen().style() == Qt.NoPen or color.alpha() == 0: - return - doFill = self.__data.brush.style() != Qt.NoBrush\ - and self.__data.brush.color().alpha() > 0 - doAlign = QwtPainter.roundingAlignment(painter) - mapper = QwtPointMapper() - mapper.setBoundingRect(canvasRect) - mapper.setFlag(QwtPointMapper.RoundPoints, doAlign) - if self.__data.paintAttributes & self.FilterPoints: - if color.alpha() == 255\ - and not (painter.renderHints() & QPainter.Antialiasing): - mapper.setFlag(QwtPointMapper.WeedOutPoints, True) + """ + Draw dots + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`draw()`, :py:meth:`drawSticks()`, + :py:meth:`drawSteps()`, :py:meth:`drawLines()` + """ + doFill = ( + self.__data.brush.style() != Qt.NoBrush + and self.__data.brush.color().alpha() > 0 + ) + polyline = series_to_polyline(xMap, yMap, self.data(), from_, to) + painter.drawPoints(polyline) if doFill: - mapper.setFlag(QwtPointMapper.WeedOutPoints, False) - points = mapper.toPointsF(xMap, yMap, self.data(), from_, to) - QwtPainter.drawPoints(painter, points) - self.fillCurve(painter, xMap, yMap, canvasRect, points) - elif self.__data.paintAttributes & self.ImageBuffer: - image = mapper.toImage(xMap, yMap, self.data(), from_, to, - self.__data.pen, - painter.testRenderHint(QPainter.Antialiasing), - self.renderThreadCount()) - painter.drawImage(canvasRect.toAlignedRect(), image) - elif self.__data.paintAttributes & self.MinimizeMemory: - series = self.data() - for i in range(from_, to+1): - sample = series.sample(i) - xi = xMap.transform(sample.x()) - yi = yMap.transform(sample.y()) - if doAlign: - xi = round(xi) - yi = round(yi) - QwtPainter.drawPoint(painter, QPointF(xi, yi)) - else: - if doAlign: - points = mapper.toPoints(xMap, yMap, self.data(), from_, to) - QwtPainter.drawPoints(painter, points) - else: - points = mapper.toPointsF(xMap, yMap, self.data(), from_, to) - QwtPainter.drawPoints(painter, points) - + self.fillCurve(painter, xMap, yMap, canvasRect, polyline) + def drawSteps(self, painter, xMap, yMap, canvasRect, from_, to): - doAlign = QwtPainter.roundingAlignment(painter) - polygon = QPolygonF(2*(to-from_)+1) + """ + Draw steps + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`draw()`, :py:meth:`drawSticks()`, + :py:meth:`drawDots()`, :py:meth:`drawLines()` + """ + size = 2 * (to - from_) + 1 + if QT_API == "pyside6": + polygon = QPolygonF() + polygon.resize(size) + elif QT_API == "pyqt6": + polygon = QPolygonF([QPointF(0, 0)] * size) + else: + polygon = QPolygonF(size) inverted = self.orientation() == Qt.Vertical if self.__data.attributes & self.Inverted: inverted = not inverted series = self.data() ip = 0 - for i in range(from_, to+1): + for i in range(from_, to + 1): sample = series.sample(i) xi = xMap.transform(sample.x()) yi = yMap.transform(sample.y()) - if doAlign: - xi = round(xi) - yi = round(yi) if ip > 0: - p0 = polygon[ip-2] + p0 = polygon[ip - 2] if inverted: - polygon[ip-1] = QPointF(p0.x(), yi) + polygon[ip - 1] = QPointF(p0.x(), yi) else: - polygon[ip-1] = QPointF(xi, p0.y()) + polygon[ip - 1] = QPointF(xi, p0.y()) polygon[ip] = QPointF(xi, yi) ip += 2 - if self.__data.paintAttributes & self.ClipPolygons: - clipped = QwtClipper().clipPolygonF(canvasRect, polygon, False) - QwtPainter.drawPolyline(painter, clipped) - else: - QwtPainter.drawPolyline(painter, polygon) + painter.drawPolyline(polygon) if self.__data.brush.style() != Qt.NoBrush: self.fillCurve(painter, xMap, yMap, canvasRect, polygon) - + def setCurveAttribute(self, attribute, on=True): + """ + Specify an attribute for drawing the curve + + Supported curve attributes: + + * `QwtPlotCurve.Inverted` + + :param int attribute: Curve attribute + :param bool on: On/Off + + .. seealso:: + + :py:meth:`testCurveAttribute()` + """ if (self.__data.attributes & attribute) == on: return if on: @@ -379,18 +745,33 @@ def setCurveAttribute(self, attribute, on=True): else: self.__data.attributes &= ~attribute self.itemChanged() - + def testCurveAttribute(self, attribute): + """ + :return: True, if attribute is enabled + + .. seealso:: + + :py:meth:`setCurveAttribute()` + """ return self.__data.attributes & attribute - - def setCurveFitter(self, curveFitter): - self.__data.curveFitter = curveFitter - self.itemChanged() - - def curveFitter(self): - return self.__data.curveFitter - + def fillCurve(self, painter, xMap, yMap, canvasRect, polygon): + """ + Fill the area between the curve and the baseline with + the curve brush + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param QPolygonF polygon: Polygon - will be modified ! + + .. seealso:: + + :py:meth:`setBrush()`, :py:meth:`setBaseline()`, + :py:meth:`setStyle()` + """ if self.__data.brush.style() == Qt.NoBrush: return self.closePolyline(painter, xMap, yMap, polygon) @@ -399,59 +780,113 @@ def fillCurve(self, painter, xMap, yMap, canvasRect, polygon): brush = self.__data.brush if not brush.color().isValid(): brush.setColor(self.__data.pen.color()) - if self.__data.paintAttributes & self.ClipPolygons: - polygon = QwtClipper().clipPolygonF(canvasRect, polygon, True) painter.save() painter.setPen(Qt.NoPen) painter.setBrush(brush) - QwtPainter.drawPolygon(painter, polygon) + painter.drawPolygon(polygon) painter.restore() - + def closePolyline(self, painter, xMap, yMap, polygon): + """ + Complete a polygon to be a closed polygon including the + area between the original polygon and the baseline. + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QPolygonF polygon: Polygon to be completed + """ if polygon.size() < 2: return - doAlign = QwtPainter.roundingAlignment(painter) baseline = self.__data.baseline - if self.orientation() == Qt.Vertical: + if self.orientation() == Qt.Horizontal: if yMap.transformation(): baseline = yMap.transformation().bounded(baseline) refY = yMap.transform(baseline) - if doAlign: - refY = round(refY) - polygon += QPointF(polygon.last().x(), refY) - polygon += QPointF(polygon.first().x(), refY) + polygon.append(QPointF(polygon.last().x(), refY)) + polygon.append(QPointF(polygon.first().x(), refY)) else: if xMap.transformation(): baseline = xMap.transformation().bounded(baseline) refX = xMap.transform(baseline) - if doAlign: - refX = round(refX) - polygon += QPointF(refX, polygon.last().y()) - polygon += QPointF(refX, polygon.first().y()) - + polygon.append(QPointF(refX, polygon.last().y())) + polygon.append(QPointF(refX, polygon.first().y())) + def drawSymbols(self, painter, symbol, xMap, yMap, canvasRect, from_, to): - mapper = QwtPointMapper() - mapper.setFlag(QwtPointMapper.RoundPoints, - QwtPainter.roundingAlignment(painter)) - mapper.setFlag(QwtPointMapper.WeedOutPoints, - self.testPaintAttribute(QwtPlotCurve.FilterPoints)) - mapper.setBoundingRect(canvasRect) + """ + Draw symbols + + :param QPainter painter: Painter + :param qwt.symbol.QwtSymbol symbol: Curve symbol + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + :py:meth:`setSymbol()`, :py:meth:`drawSeries()`, + :py:meth:`drawCurve()` + """ chunkSize = 500 - for i in range(from_, to+1, chunkSize): - n = min([chunkSize, to-i+1]) - points = mapper.toPointsF(xMap, yMap, self.data(), i, i+n-1) + for i in range(from_, to + 1, chunkSize): + n = min([chunkSize, to - i + 1]) + points = series_to_polyline(xMap, yMap, self.data(), i, i + n - 1) if points.size() > 0: symbol.drawSymbols(painter, points) - + def setBaseline(self, value): + """ + Set the value of the baseline + + The baseline is needed for filling the curve with a brush or + the Sticks drawing style. + + The interpretation of the baseline depends on the `orientation()`. + With `Qt.Horizontal`, the baseline is interpreted as a horizontal line + at y = baseline(), with `Qt.Vertical`, it is interpreted as a vertical + line at x = baseline(). + + The default value is 0.0. + + :param float value: Value of the baseline + + .. seealso:: + + :py:meth:`baseline()`, :py:meth:`setBrush()`, + :py:meth:`setStyle()` + """ if self.__data.baseline != value: self.__data.baseline = value self.itemChanged() - + def baseline(self): + """ + :return: Value of the baseline + + .. seealso:: + + :py:meth:`setBaseline()` + """ return self.__data.baseline - + def closestPoint(self, pos): + """ + Find the closest curve point for a specific position + + :param QPoint pos: Position, where to look for the closest curve point + :return: tuple `(index, dist)` + + `dist` is the distance between the position and the closest curve + point. `index` is the index of the closest curve point, or -1 if + none can be found ( f.e when the curve has no points ). + + .. note:: + + `closestPoint()` implements a dumb algorithm, that iterates + over all points + """ numSamples = self.dataSize() if self.plot() is None or numSamples <= 0: return -1 @@ -462,75 +897,158 @@ def closestPoint(self, pos): dmin = 1.0e10 for i in range(numSamples): sample = series.sample(i) - cx = xMap.transform(sample.x())-pos.x() - cy = yMap.transform(sample.y())-pos.y() - f = qwtSqr(cx)+qwtSqr(cy) + cx = xMap.transform(sample.x()) - pos.x() + cy = yMap.transform(sample.y()) - pos.y() + f = qwtSqr(cx) + qwtSqr(cy) if f < dmin: index = i dmin = f - dist = np.sqrt(dmin) + dist = math.sqrt(dmin) return index, dist - + def legendIcon(self, index, size): + """ + :param int index: Index of the legend entry (ignored as there is only one) + :param QSizeF size: Icon size + :return: Icon representing the curve on the legend + + .. seealso:: + + :py:meth:`qwt.plot.QwtPlotItem.setLegendIconSize()`, + :py:meth:`qwt.plot.QwtPlotItem.legendData()` + """ if size.isEmpty(): return QwtGraphic() graphic = QwtGraphic() graphic.setDefaultSize(size) graphic.setRenderHint(QwtGraphic.RenderPensUnscaled, True) painter = QPainter(graphic) - painter.setRenderHint(QPainter.Antialiasing, - self.testRenderHint(QwtPlotItem.RenderAntialiased)) - if self.__data.legendAttributes == 0 or\ - (self.__data.legendAttributes & QwtPlotCurve.LegendShowBrush): + painter.setRenderHint( + QPainter.Antialiasing, self.testRenderHint(QwtPlotItem.RenderAntialiased) + ) + if self.__data.legendAttributes == 0 or ( + self.__data.legendAttributes & QwtPlotCurve.LegendShowBrush + ): brush = self.__data.brush if brush.style() == Qt.NoBrush and self.__data.legendAttributes == 0: if self.style() != QwtPlotCurve.NoCurve: brush = QBrush(self.pen().color()) - elif self.__data.symbol and\ - self.__data.symbol.style() != QwtSymbol.NoSymbol: + elif ( + self.__data.symbol + and self.__data.symbol.style() != QwtSymbol.NoSymbol + ): brush = QBrush(self.__data.symbol.pen().color()) if brush.style() != Qt.NoBrush: r = QRectF(0, 0, size.width(), size.height()) painter.fillRect(r, brush) if self.__data.legendAttributes & QwtPlotCurve.LegendShowLine: if self.pen() != Qt.NoPen: - pn = self.pen() -# pn.setCapStyle(Qt.FlatCap) - painter.setPen(pn) - y = .5*size.height() - QwtPainter.drawLine(painter, 0., y, size.width(), y) + painter.setPen(self.pen()) + y = size.height() // 2 + painter.drawLine(QLineF(0, y, size.width(), y)) if self.__data.legendAttributes & QwtPlotCurve.LegendShowSymbol: if self.__data.symbol: r = QRectF(0, 0, size.width(), size.height()) self.__data.symbol.drawSymbol(painter, r) - return graphic + return graphic + + def setData(self, *args, **kwargs): + """ + Initialize data with a series data object or an array of points. + + .. py:method:: setData(data): + + :param data: Series data (e.g. `QwtPointArrayData` instance) + :type data: .plot_series.QwtSeriesData + + .. py:method:: setData(xData, yData, [size=None], [finite=True]): + + Initialize data with `x` and `y` arrays. + + This signature was removed in Qwt6 and is temporarily maintained here to ensure compatibility with Qwt5. + + Same as `setSamples(x, y, [size=None], [finite=True])` + + :param x: List/array of x values + :param y: List/array of y values + :param size: size of xData and yData + :type size: int or None + :param bool finite: if True, keep only finite array elements (remove all infinity and not a number values), otherwise do not filter array elements - def setData(self, *args): - """Compatibility with Qwt5""" - if len(args) == 1: + .. seealso:: + + :py:meth:`setSamples()` + """ + if len(args) == 1 and not kwargs: super(QwtPlotCurve, self).setData(*args) - elif len(args) == 2: - self.setSamples(*args) + elif len(args) in (2, 3, 4): + self.setSamples(*args, **kwargs) else: - raise TypeError("%s().setData() takes 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def setSamples(self, *args): - if len(args) == 1: - samples, = args + raise TypeError( + "%s().setData() takes 1, 2, 3 or 4 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + + def setSamples(self, *args, **kwargs): + """ + Initialize data with an array of points. + + .. py:method:: setSamples(data): + + :param data: Series data (e.g. `QwtPointArrayData` instance) + :type data: .plot_series.QwtSeriesData + + + .. py:method:: setSamples(samples): + + Same as `setData(QwtPointArrayData(samples))` + + :param samples: List/array of points + + .. py:method:: setSamples(xData, yData, [size=None], [finite=True]): + + Same as `setData(QwtPointArrayData(xData, yData, [size=None]))` + + :param xData: List/array of x values + :param yData: List/array of y values + :param size: size of xData and yData + :type size: int or None + :param bool finite: if True, keep only finite array elements (remove all infinity and not a number values), otherwise do not filter array elements + + .. seealso:: + + :py:class:`.plot_series.QwtPointArrayData` + """ + if len(args) == 1 and not kwargs: + (samples,) = args if isinstance(samples, QwtSeriesData): self.setData(samples) else: - self.setData(QwtPointSeriesData(samples)) - elif len(args) == 3: - xData, yData, size = args - self.setData(QwtPointArrayData(xData, yData, size)) - elif len(args) == 2: - xData, yData = args - self.setData(QwtPointArrayData(xData, yData)) + self.setData(QwtPointArrayData(samples)) + elif len(args) >= 2: + xData, yData = args[:2] + try: + size = kwargs.pop("size") + except KeyError: + size = None + try: + finite = kwargs.pop("finite") + except KeyError: + finite = None + if kwargs: + raise TypeError( + "%s().setSamples(): unknown %s keyword " + "argument(s)" + % (self.__class__.__name__, ", ".join(list(kwargs.keys()))) + ) + for arg in args[2:]: + if isinstance(arg, bool): + finite = arg + elif isinstance(arg, int): + size = arg + self.setData(QwtPointArrayData(xData, yData, size=size, finite=finite)) else: - raise TypeError("%s().setSamples() takes 1, 2 or 3 argument(s) "\ - "(%s given)" % (self.__class__.__name__, len(args))) - - def setRawSamples(self, xData, yData, size): - self.setData(QwtCPointerData(xData, yData, size)) + raise TypeError( + "%s().setSamples() takes 1, 2 or 3 argument(s) " + "(%s given)" % (self.__class__.__name__, len(args)) + ) diff --git a/qwt/plot_directpainter.py b/qwt/plot_directpainter.py index a57b486..06697d3 100644 --- a/qwt/plot_directpainter.py +++ b/qwt/plot_directpainter.py @@ -5,30 +5,44 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.qt.QtGui import QPainter, QRegion -from qwt.qt.QtCore import QObject, QT_VERSION, Qt, QEvent +""" +QwtPlotDirectPainter +-------------------- + +.. autoclass:: QwtPlotDirectPainter + :members: +""" + +from qtpy.QtCore import QEvent, QObject, Qt +from qtpy.QtGui import QPainter, QRegion from qwt.plot import QwtPlotItem from qwt.plot_canvas import QwtPlotCanvas def qwtRenderItem(painter, canvasRect, seriesItem, from_, to): - #TODO: A minor performance improvement is possible with caching the maps + # TODO: A minor performance improvement is possible with caching the maps plot = seriesItem.plot() xMap = plot.canvasMap(seriesItem.xAxis()) yMap = plot.canvasMap(seriesItem.yAxis()) - painter.setRenderHint(QPainter.Antialiasing, - seriesItem.testRenderHint(QwtPlotItem.RenderAntialiased)) + painter.setRenderHint( + QPainter.Antialiasing, seriesItem.testRenderHint(QwtPlotItem.RenderAntialiased) + ) seriesItem.drawSeries(painter, xMap, yMap, canvasRect, from_, to) def qwtHasBackingStore(canvas): - return canvas.testPaintAttribute(QwtPlotCanvas.BackingStore)\ - and canvas.backingStore() + return ( + canvas.testPaintAttribute(QwtPlotCanvas.BackingStore) + and canvas.backingStore() is not None + and not canvas.backingStore().isNull() + ) -class QwtPlotDirectPainter_PrivateData(object): +class QwtPlotDirectPainter_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.attributes = 0 self.hasClipping = False self.seriesItem = None # QwtPlotSeriesItem @@ -39,78 +53,185 @@ def __init__(self): class QwtPlotDirectPainter(QObject): - + """ + Painter object trying to paint incrementally + + Often applications want to display samples while they are + collected. When there are too many samples complete replots + will be expensive to be processed in a collection cycle. + + `QwtPlotDirectPainter` offers an API to paint + subsets (f.e all additions points) without erasing/repainting + the plot canvas. + + On certain environments it might be important to calculate a proper + clip region before painting. F.e. for Qt Embedded only the clipped part + of the backing store will be copied to a (maybe unaccelerated) + frame buffer. + + .. warning:: + + Incremental painting will only help when no replot is triggered + by another operation (like changing scales) and nothing needs + to be erased. + + Paint attributes: + + * `QwtPlotDirectPainter.AtomicPainter`: + + Initializing a `QPainter` is an expensive operation. + When `AtomicPainter` is set each call of `drawSeries()` opens/closes + a temporary `QPainter`. Otherwise `QwtPlotDirectPainter` tries to + use the same `QPainter` as long as possible. + + * `QwtPlotDirectPainter.FullRepaint`: + + When `FullRepaint` is set the plot canvas is explicitly repainted + after the samples have been rendered. + + * `QwtPlotDirectPainter.CopyBackingStore`: + + When `QwtPlotCanvas.BackingStore` is enabled the painter + has to paint to the backing store and the widget. In certain + situations/environments it might be faster to paint to + the backing store only and then copy the backing store to the canvas. + This flag can also be useful for settings, where Qt fills the + the clip region with the widget background. + """ + # enum Attribute AtomicPainter = 0x01 FullRepaint = 0x02 CopyBackingStore = 0x04 - + def __init__(self, parent=None): QObject.__init__(self, parent) self.__data = QwtPlotDirectPainter_PrivateData() - + def setAttribute(self, attribute, on=True): + """ + Change an attribute + + :param int attribute: Attribute to change + :param bool on: On/Off + + .. seealso:: + + :py:meth:`testAttribute()` + """ if self.testAttribute(attribute) != on: self.__data.attributes |= attribute else: self.__data.attributes &= ~attribute if attribute == self.AtomicPainter and on: self.reset() - + def testAttribute(self, attribute): + """ + :param int attribute: Attribute to be tested + :return: True, when attribute is enabled + + .. seealso:: + + :py:meth:`setAttribute()` + """ return self.__data.attributes & attribute - + def setClipping(self, enable): + """ + En/Disables clipping + + :param bool enable: Enables clipping is true, disable it otherwise + + .. seealso:: + + :py:meth:`hasClipping()`, :py:meth:`clipRegion()`, + :py:meth:`setClipRegion()` + """ self.__data.hasClipping = enable - + def hasClipping(self): + """ + :return: Return true, when clipping is enabled + + .. seealso:: + + :py:meth:`setClipping()`, :py:meth:`clipRegion()`, + :py:meth:`setClipRegion()` + """ return self.__data.hasClipping - + def setClipRegion(self, region): + """ + Assign a clip region and enable clipping + + Depending on the environment setting a proper clip region might + improve the performance heavily. F.e. on Qt embedded only the clipped + part of the backing store will be copied to a (maybe unaccelerated) + frame buffer device. + + :param QRegion region: Clip region + + .. seealso:: + + :py:meth:`hasClipping()`, :py:meth:`setClipping()`, + :py:meth:`clipRegion()` + """ self.__data.clipRegion = region self.__data.hasClipping = True - + def clipRegion(self): + """ + :return: Return Currently set clip region. + + .. seealso:: + + :py:meth:`hasClipping()`, :py:meth:`setClipping()`, + :py:meth:`setClipRegion()` + """ return self.__data.clipRegion - + def drawSeries(self, seriesItem, from_, to): - """When observing an measurement while it is running, new points have - to be added to an existing seriesItem. drawSeries() can be used to + """ + Draw a set of points of a seriesItem. + + When observing a measurement while it is running, new points have + to be added to an existing seriesItem. drawSeries() can be used to display them avoiding a complete redraw of the canvas. - Setting plot().canvas().setAttribute(Qt.WA_PaintOutsidePaintEvent, True) + Setting `plot().canvas().setAttribute(Qt.WA_PaintOutsidePaintEvent, True)` will result in faster painting, if the paint engine of the canvas widget - supports this feature.""" + supports this feature. + + :param qwt.plot_series.QwtPlotSeriesItem seriesItem: Item to be painted + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the series will be painted to its last point. + """ if seriesItem is None or seriesItem.plot() is None: return canvas = seriesItem.plot().canvas() canvasRect = canvas.contentsRect() - plotCanvas = canvas #XXX: cast to QwtPlotCanvas - if plotCanvas and qwtHasBackingStore(plotCanvas): - painter = QPainter(plotCanvas.backingStore()) #XXX: cast plotCanvas.backingStore() to QPixmap + if canvas and qwtHasBackingStore(canvas): + painter = QPainter(canvas.backingStore()) if self.__data.hasClipping: painter.setClipRegion(self.__data.clipRegion) qwtRenderItem(painter, canvasRect, seriesItem, from_, to) + painter.end() if self.testAttribute(self.FullRepaint): - plotCanvas.repaint() + canvas.repaint() return - immediatePaint = True - if not canvas.testAttribute(Qt.WA_WState_InPaintEvent): - if QT_VERSION >= 0x050000 or\ - not canvas.testAttribute(Qt.WA_PaintOutsidePaintEvent): - immediatePaint = False - if immediatePaint: + if canvas.testAttribute(Qt.WA_WState_InPaintEvent): if not self.__data.painter.isActive(): self.reset() self.__data.painter.begin(canvas) canvas.installEventFilter(self) if self.__data.hasClipping: self.__data.painter.setClipRegion( - QRegion(canvasRect) & self.__data.clipRegion) + QRegion(canvasRect) & self.__data.clipRegion + ) elif not self.__data.painter.hasClipping(): self.__data.painter.setClipRect(canvasRect) - qwtRenderItem(self.__data.painter, - canvasRect, seriesItem, from_, to) + qwtRenderItem(self.__data.painter, canvasRect, seriesItem, from_, to) if self.__data.attributes & self.AtomicPainter: self.reset() elif self.__data.hasClipping: @@ -127,33 +248,39 @@ def drawSeries(self, seriesItem, from_, to): canvas.repaint(clipRegion) canvas.removeEventFilter(self) self.__data.seriesItem = None - + def reset(self): + """Close the internal QPainter""" if self.__data.painter.isActive(): - w = self.__data.painter.device() #XXX: cast to QWidget + w = self.__data.painter.device() # XXX: cast to QWidget if w: w.removeEventFilter(self) self.__data.painter.end() - + def eventFilter(self, obj_, event): if event.type() == QEvent.Paint: self.reset() if self.__data.seriesItem: - pe = event #XXX: cast to QPaintEvent + pe = event # XXX: cast to QPaintEvent canvas = self.__data.seriesItem.plot().canvas() painter = QPainter(canvas) painter.setClipRegion(pe.region()) doCopyCache = self.testAttribute(self.CopyBackingStore) if doCopyCache: - plotCanvas = canvas #XXX: cast to QwtPlotCanvas + plotCanvas = canvas # XXX: cast to QwtPlotCanvas if plotCanvas: doCopyCache = qwtHasBackingStore(plotCanvas) if doCopyCache: - painter.drawPixmap(plotCanvas.contentsRect().topLeft(), - plotCanvas.backingStore()) + painter.drawPixmap( + plotCanvas.rect().topLeft(), plotCanvas.backingStore() + ) if not doCopyCache: - qwtRenderItem(painter, canvas.contentsRect(), - self.__data.seriesItem, - self.__data.from_, self.__data.to) + qwtRenderItem( + painter, + canvas.contentsRect(), + self.__data.seriesItem, + self.__data.from_, + self.__data.to, + ) return True return False diff --git a/qwt/plot_grid.py b/qwt/plot_grid.py index d46cdf9..a75a07c 100644 --- a/qwt/plot_grid.py +++ b/qwt/plot_grid.py @@ -5,18 +5,27 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.scale_div import QwtScaleDiv -from qwt.plot import QwtPlotItem -from qwt.text import QwtText -from qwt.painter import QwtPainter -from qwt.math import qwtFuzzyGreaterOrEqual, qwtFuzzyLessOrEqual +""" +QwtPlotGrid +----------- + +.. autoclass:: QwtPlotGrid + :members: +""" -from qwt.qt.QtGui import QPen -from qwt.qt.QtCore import Qt +from qtpy.QtCore import QLineF, QObject, Qt +from qtpy.QtGui import QPen + +from qwt._math import qwtFuzzyGreaterOrEqual, qwtFuzzyLessOrEqual +from qwt.plot import QwtPlotItem +from qwt.qthelpers import qcolor_from_str +from qwt.scale_div import QwtScaleDiv -class QwtPlotGrid_PrivateData(object): +class QwtPlotGrid_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.xEnabled = True self.yEnabled = True self.xMinEnabled = False @@ -28,160 +37,483 @@ def __init__(self): class QwtPlotGrid(QwtPlotItem): - def __init__(self): - QwtPlotItem.__init__(self, QwtText("Grid")) + """ + A class which draws a coordinate grid + + The `QwtPlotGrid` class can be used to draw a coordinate grid. + A coordinate grid consists of major and minor vertical + and horizontal grid lines. The locations of the grid lines + are determined by the X and Y scale divisions which can + be assigned with `setXDiv()` and `setYDiv()`. + The `draw()` member draws the grid within a bounding + rectangle. + """ + + def __init__(self, title="Grid"): + QwtPlotItem.__init__(self, title) self.__data = QwtPlotGrid_PrivateData() self.setItemInterest(QwtPlotItem.ScaleInterest, True) - self.setZ(10.) - + self.setZ(10.0) + + @classmethod + def make( + cls, + plot=None, + z=None, + enablemajor=None, + enableminor=None, + color=None, + width=None, + style=None, + mincolor=None, + minwidth=None, + minstyle=None, + ): + """ + Create and setup a new `QwtPlotGrid` object (convenience function). + + :param plot: Plot to attach the curve to + :type plot: qwt.plot.QwtPlot or None + :param z: Z-value + :type z: float or None + :param enablemajor: Tuple of two boolean values (x, y) for enabling major grid lines + :type enablemajor: bool or None + :param enableminor: Tuple of two boolean values (x, y) for enabling minor grid lines + :type enableminor: bool or None + :param color: Pen color for both major and minor grid lines (default: Qt.gray) + :type color: QColor or str or None + :param width: Pen width for both major and minor grid lines (default: 1.0) + :type width: float or None + :param style: Pen style for both major and minor grid lines (default: Qt.DotLine) + :type style: Qt.PenStyle or None + :param mincolor: Pen color for minor grid lines only (default: Qt.gray) + :type mincolor: QColor or str or None + :param minwidth: Pen width for minor grid lines only (default: 1.0) + :type minwidth: float or None + :param minstyle: Pen style for minor grid lines only (default: Qt.DotLine) + :type minstyle: Qt.PenStyle or None + + .. seealso:: + + :py:meth:`setMinorPen()`, :py:meth:`setMajorPen()` + """ + item = cls() + if z is not None: + item.setZ(z) + color = qcolor_from_str(color, Qt.gray) + width = 1.0 if width is None else float(width) + style = Qt.DotLine if style is None else style + item.setPen(QPen(color, width, style)) + if mincolor is not None or minwidth is not None or minstyle is not None: + mincolor = qcolor_from_str(mincolor, Qt.gray) + minwidth = 1.0 if width is None else minwidth + minstyle = Qt.DotLine if style is None else minstyle + item.setMinorPen(QPen(mincolor, minwidth, minstyle)) + if enablemajor is not None: + if isinstance(enablemajor, tuple) and len(enablemajor) == 2: + item.enableX(enablemajor[0]) + item.enableY(enablemajor[1]) + else: + raise TypeError( + "Invalid enablemajor %r (expecting tuple of two booleans)" + % enablemajor + ) + if enableminor is not None: + if isinstance(enableminor, tuple) and len(enableminor) == 2: + item.enableXMin(enableminor[0]) + item.enableYMin(enableminor[1]) + else: + raise TypeError( + "Invalid enableminor %r (expecting tuple of two booleans)" + % enableminor + ) + if plot is not None: + item.attach(plot) + return item + def rtti(self): + """ + :return: Return `QwtPlotItem.Rtti_PlotGrid` + """ return QwtPlotItem.Rtti_PlotGrid - + def enableX(self, on): + """ + Enable or disable vertical grid lines + + :param bool on: Enable (true) or disable + + .. seealso:: + + :py:meth:`enableXMin()` + """ if self.__data.xEnabled != on: self.__data.xEnabled = on self.legendChanged() self.itemChanged() - + def enableY(self, on): + """ + Enable or disable horizontal grid lines + + :param bool on: Enable (true) or disable + + .. seealso:: + + :py:meth:`enableYMin()` + """ if self.__data.yEnabled != on: self.__data.yEnabled = on self.legendChanged() self.itemChanged() - + def enableXMin(self, on): + """ + Enable or disable minor vertical grid lines. + + :param bool on: Enable (true) or disable + + .. seealso:: + + :py:meth:`enableX()` + """ if self.__data.xMinEnabled != on: self.__data.xMinEnabled = on self.legendChanged() self.itemChanged() - + def enableYMin(self, on): + """ + Enable or disable minor horizontal grid lines. + + :param bool on: Enable (true) or disable + + .. seealso:: + + :py:meth:`enableY()` + """ if self.__data.yMinEnabled != on: self.__data.yMinEnabled = on self.legendChanged() self.itemChanged() def setXDiv(self, scaleDiv): + """ + Assign an x axis scale division + + :param qwt.scale_div.QwtScaleDiv scaleDiv: Scale division + """ if self.__data.xScaleDiv != scaleDiv: self.__data.xScaleDiv = scaleDiv self.itemChanged() def setYDiv(self, scaleDiv): + """ + Assign an y axis scale division + + :param qwt.scale_div.QwtScaleDiv scaleDiv: Scale division + """ if self.__data.yScaleDiv != scaleDiv: self.__data.yScaleDiv = scaleDiv self.itemChanged() def setPen(self, *args): + """ + Build and/or assign a pen for both major and minor grid lines + + .. py:method:: setPen(color, width, style) + :noindex: + + Build and assign a pen for both major and minor grid lines + + In Qt5 the default pen width is 1.0 ( 0.0 in Qt4 ) what makes it + non cosmetic (see `QPen.isCosmetic()`). This method signature has + been introduced to hide this incompatibility. + + :param QColor color: Pen color + :param float width: Pen width + :param Qt.PenStyle style: Pen style + + .. py:method:: setPen(pen) + :noindex: + + Assign a pen for both major and minor grid lines + + :param QPen pen: New pen + + .. seealso:: + + :py:meth:`pen()`, :py:meth:`brush()` + """ if len(args) == 3: color, width, style = args self.setPen(QPen(color, width, style)) elif len(args) == 1: - pen, = args + (pen,) = args if self.__data.majorPen != pen or self.__data.minorPen != pen: self.__data.majorPen = pen self.__data.minorPen = pen self.legendChanged() self.itemChanged() else: - raise TypeError("%s().setPen() takes 1 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s().setPen() takes 1 or 3 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) def setMajorPen(self, *args): + """ + Build and/or assign a pen for both major grid lines + + .. py:method:: setMajorPen(color, width, style) + :noindex: + + Build and assign a pen for both major grid lines + + In Qt5 the default pen width is 1.0 ( 0.0 in Qt4 ) what makes it + non cosmetic (see `QPen.isCosmetic()`). This method signature has + been introduced to hide this incompatibility. + + :param QColor color: Pen color + :param float width: Pen width + :param Qt.PenStyle style: Pen style + + .. py:method:: setMajorPen(pen) + :noindex: + + Assign a pen for the major grid lines + + :param QPen pen: New pen + + .. seealso:: + + :py:meth:`majorPen()`, :py:meth:`setMinorPen()`, + :py:meth:`setPen()`, :py:meth:`pen()`, :py:meth:`brush()` + """ if len(args) == 3: color, width, style = args self.setMajorPen(QPen(color, width, style)) elif len(args) == 1: - pen, = args + (pen,) = args if self.__data.majorPen != pen: self.__data.majorPen = pen self.legendChanged() self.itemChanged() else: - raise TypeError("%s().setMajorPen() takes 1 or 3 argument(s) (%s "\ - "given)" % (self.__class__.__name__, len(args))) + raise TypeError( + "%s().setMajorPen() takes 1 or 3 argument(s) (%s " + "given)" % (self.__class__.__name__, len(args)) + ) def setMinorPen(self, *args): + """ + Build and/or assign a pen for both minor grid lines + + .. py:method:: setMinorPen(color, width, style) + :noindex: + + Build and assign a pen for both minor grid lines + + In Qt5 the default pen width is 1.0 ( 0.0 in Qt4 ) what makes it + non cosmetic (see `QPen.isCosmetic()`). This method signature has + been introduced to hide this incompatibility. + + :param QColor color: Pen color + :param float width: Pen width + :param Qt.PenStyle style: Pen style + + .. py:method:: setMinorPen(pen) + :noindex: + + Assign a pen for the minor grid lines + + :param QPen pen: New pen + + .. seealso:: + + :py:meth:`minorPen()`, :py:meth:`setMajorPen()`, + :py:meth:`setPen()`, :py:meth:`pen()`, :py:meth:`brush()` + """ if len(args) == 3: color, width, style = args self.setMinorPen(QPen(color, width, style)) elif len(args) == 1: - pen, = args + (pen,) = args if self.__data.minorPen != pen: self.__data.minorPen = pen self.legendChanged() self.itemChanged() else: - raise TypeError("%s().setMinorPen() takes 1 or 3 argument(s) (%s "\ - "given)" % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s().setMinorPen() takes 1 or 3 argument(s) (%s " + "given)" % (self.__class__.__name__, len(args)) + ) + def draw(self, painter, xMap, yMap, canvasRect): + """ + Draw the grid + + The grid is drawn into the bounding rectangle such that + grid lines begin and end at the rectangle's borders. The X and Y + maps are used to map the scale divisions into the drawing region + screen. + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: X axis map + :param qwt.scale_map.QwtScaleMap yMap: Y axis + :param QRectF canvasRect: Contents rectangle of the plot canvas + """ minorPen = QPen(self.__data.minorPen) minorPen.setCapStyle(Qt.FlatCap) painter.setPen(minorPen) if self.__data.xEnabled and self.__data.xMinEnabled: - self.drawLines(painter, canvasRect, Qt.Vertical, xMap, - self.__data.xScaleDiv.ticks(QwtScaleDiv.MinorTick)) - self.drawLines(painter, canvasRect, Qt.Vertical, xMap, - self.__data.xScaleDiv.ticks(QwtScaleDiv.MediumTick)) + self.drawLines( + painter, + canvasRect, + Qt.Vertical, + xMap, + self.__data.xScaleDiv.ticks(QwtScaleDiv.MinorTick), + ) + self.drawLines( + painter, + canvasRect, + Qt.Vertical, + xMap, + self.__data.xScaleDiv.ticks(QwtScaleDiv.MediumTick), + ) if self.__data.yEnabled and self.__data.yMinEnabled: - self.drawLines(painter, canvasRect, Qt.Horizontal, yMap, - self.__data.yScaleDiv.ticks(QwtScaleDiv.MinorTick)) - self.drawLines(painter, canvasRect, Qt.Horizontal, yMap, - self.__data.yScaleDiv.ticks(QwtScaleDiv.MediumTick)) + self.drawLines( + painter, + canvasRect, + Qt.Horizontal, + yMap, + self.__data.yScaleDiv.ticks(QwtScaleDiv.MinorTick), + ) + self.drawLines( + painter, + canvasRect, + Qt.Horizontal, + yMap, + self.__data.yScaleDiv.ticks(QwtScaleDiv.MediumTick), + ) majorPen = QPen(self.__data.majorPen) majorPen.setCapStyle(Qt.FlatCap) painter.setPen(majorPen) if self.__data.xEnabled: - self.drawLines(painter, canvasRect, Qt.Vertical, xMap, - self.__data.xScaleDiv.ticks(QwtScaleDiv.MajorTick)) + self.drawLines( + painter, + canvasRect, + Qt.Vertical, + xMap, + self.__data.xScaleDiv.ticks(QwtScaleDiv.MajorTick), + ) if self.__data.yEnabled: - self.drawLines(painter, canvasRect, Qt.Horizontal, yMap, - self.__data.yScaleDiv.ticks(QwtScaleDiv.MajorTick)) - + self.drawLines( + painter, + canvasRect, + Qt.Horizontal, + yMap, + self.__data.yScaleDiv.ticks(QwtScaleDiv.MajorTick), + ) + def drawLines(self, painter, canvasRect, orientation, scaleMap, values): x1 = canvasRect.left() - x2 = canvasRect.right()-1. + x2 = canvasRect.right() - 1.0 y1 = canvasRect.top() - y2 = canvasRect.bottom()-1. - doAlign = QwtPainter.roundingAlignment(painter) + y2 = canvasRect.bottom() - 1.0 for val in values: value = scaleMap.transform(val) - if doAlign: - value = round(value) if orientation == Qt.Horizontal: - if qwtFuzzyGreaterOrEqual(value, y1) and\ - qwtFuzzyLessOrEqual(value, y2): - QwtPainter.drawLine(painter, x1, value, x2, value) + if qwtFuzzyGreaterOrEqual(value, y1) and qwtFuzzyLessOrEqual(value, y2): + painter.drawLine(QLineF(x1, value, x2, value)) else: - if qwtFuzzyGreaterOrEqual(value, x1) and\ - qwtFuzzyLessOrEqual(value, x2): - QwtPainter.drawLine(painter, value, y1, value, y2) - + if qwtFuzzyGreaterOrEqual(value, x1) and qwtFuzzyLessOrEqual(value, x2): + painter.drawLine(QLineF(value, y1, value, y2)) + def majorPen(self): + """ + :return: the pen for the major grid lines + + .. seealso:: + + :py:meth:`setMajorPen()`, :py:meth:`setMinorPen()`, + :py:meth:`setPen()` + """ return self.__data.majorPen - + def minorPen(self): + """ + :return: the pen for the minor grid lines + + .. seealso:: + + :py:meth:`setMinorPen()`, :py:meth:`setMajorPen()`, + :py:meth:`setPen()` + """ return self.__data.minorPen - + def xEnabled(self): + """ + :return: True if vertical grid lines are enabled + + .. seealso:: + + :py:meth:`enableX()` + """ return self.__data.xEnabled - + def yEnabled(self): + """ + :return: True if horizontal grid lines are enabled + + .. seealso:: + + :py:meth:`enableY()` + """ return self.__data.yEnabled - + def xMinEnabled(self): + """ + :return: True if minor vertical grid lines are enabled + + .. seealso:: + + :py:meth:`enableXMin()` + """ return self.__data.xMinEnabled - + def yMinEnabled(self): + """ + :return: True if minor horizontal grid lines are enabled + + .. seealso:: + + :py:meth:`enableYMin()` + """ return self.__data.yMinEnabled - + def xScaleDiv(self): + """ + :return: the scale division of the x axis + """ return self.__data.xScaleDiv - + def yScaleDiv(self): + """ + :return: the scale division of the y axis + """ return self.__data.yScaleDiv - + def updateScaleDiv(self, xScaleDiv, yScaleDiv): + """ + Update the grid to changes of the axes scale division + + :param qwt.scale_map.QwtScaleMap xMap: Scale division of the x-axis + :param qwt.scale_map.QwtScaleMap yMap: Scale division of the y-axis + + .. seealso:: + + :py:meth:`updateAxes()` + """ self.setXDiv(xScaleDiv) self.setYDiv(yScaleDiv) - \ No newline at end of file diff --git a/qwt/plot_histogram.py b/qwt/plot_histogram.py deleted file mode 100644 index 94fa05c..0000000 --- a/qwt/plot_histogram.py +++ /dev/null @@ -1,299 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.plot_seriesitem import QwtPlotSeriesItem -from qwt.series_store import QwtSeriesStore -from qwt.interval import QwtInterval -from qwt.series_data import QwtIntervalSeriesData -from qwt.plot import QwtPlotItem -from qwt.painter import QwtPainter -from qwt.sample import QwtIntervalSample -from qwt.column_symbol import QwtColumnRect, QwtColumnSymbol - -from qwt.qt.QtGui import QPen, QBrush, QColor, QPolygonF -from qwt.qt.QtCore import Qt, QPointF, QRectF - - -def qwtIsCombinable(d1, d2): - if d1.isValid() and d2.isValid(): - if d1.maxValue() == d2.minValue(): - if not d1.borderFlags() & QwtInterval.ExcludeMaximum\ - and d2.borderFlags() & QwtInterval.ExcludeMinimum: - return True - return False - - -class QwtPlotHistogram_PrivateData(object): - def __init__(self): - self.baseline = 0. - self.style = 0 - self.symbol = None - self.pen = QPen() - self.brush = QBrush() - - -class QwtPlotHistogram(QwtPlotSeriesItem, QwtSeriesStore): - - # enum HistogramStyle - Outline, Columns, Lines = list(range(3)) - UserStyle = 100 - - def __init__(self, title=None): - self.__data = None - self.init() - - def init(self): - self.__data = QwtPlotHistogram_PrivateData() - self.setData(QwtIntervalSeriesData()) - self.setItemAttribute(QwtPlotItem.AutoScale, True) - self.setItemAttribute(QwtPlotItem.Legend, True) - self.setZ(20.) - - def setStyle(self, style): - if style != self.__data.style: - self.__data.style = style - self.legendChanged() - self.itemChanged() - - def style(self): - return self.__data.style - - def setPen(self, *args): - if len(args) not in (1, 2, 3): - raise TypeError - if isinstance(args[0], QColor): - color = args[0] - width = 0. - style = Qt.PenStyle - if len(args) > 1: - width = args[1] - if len(args) > 2: - style = args[2] - self.setPen(QPen(color, width, style)) - else: - pen, = args - if pen != self.__data.pen: - self.__data.pen = pen - self.legendChanged() - self.itemChanged() - - def pen(self): - return self.__data.pen - - def setBrush(self, brush): - if brush != self.__data.brush: - self.__data.brush = brush - self.legendChanged() - self.itemChanged() - - def brush(self): - return self.__data.brush - - def setSymbol(self, symbol): - if symbol != self.__data.symbol: - self.__data.symbol = symbol - self.legendChanged() - self.itemChanged() - - def symbol(self): - return self.__data.symbol - - def setBaseline(self, value): - if value != self.__data.baseline: - self.__data.baseline = value - self.itemChanged() - - def baseline(self): - return self.__data.baseline - - def boundingRect(self): - rect = QRectF(self.data().boundingRect()) - if not rect.isValid(): - return rect - if self.orientation() == Qt.Horizontal: - rect = QRectF(rect.y(), rect.x(), rect.height(), rect.width()) - if rect.left() > self.__data.baseline: - rect.setLeft(self.__data.baseline) - elif rect.right() < self.__data.baseline: - rect.setRight(self.__data.baseline) - else: - if rect.bottom() < self.__data.baseline: - rect.setBottom(self.__data.baseline) - elif rect.top() > self.__data.baseline: - rect.setTop(self.__data.baseline) - return rect - - def rtti(self): - return QwtPlotItem.Rtti_PlotHistogram - - def setSamples(self, samples): - if not isinstance(samples, QwtIntervalSeriesData): - self.setData(QwtIntervalSeriesData(samples)) - else: - self.setData(samples) - - def drawSeries(self, painter, xMap, yMap, canvasRect, from_, to): - if not painter or self.dataSize() <= 0: - return - if to < 0: - to = self.dataSize()-1 - if self.__data.style == self.Outline: - self.drawOutline(painter, xMap, yMap, from_, to) - elif self.__data.style == self.Lines: - self.drawLines(painter, xMap, yMap, from_, to) - elif self.__data.style == self.Columns: - self.drawColumns(painter, xMap, yMap, from_, to) - - def drawOutline(self, painter, xMap, yMap, from_, to): - doAlign = QwtPainter.roundingAlignment(painter) - if self.orientation() == Qt.Horizontal: - v0 = xMap.transform(self.baseline()) - else: - v0 = yMap.transform(self.baseline()) - if doAlign: - v0 = round(v0) - previous = QwtIntervalSample() - polygon = QPolygonF() - for i in range(from_, to+1): - sample = self.sample(i) - if not sample.interval.isValid(): - self.flushPolygon(painter, v0, polygon) - previous = sample - continue - if previous.interval.isValid(): - if not qwtIsCombinable(previous.interval, sample.interval): - self.flushPolygon(painter, v0, polygon) - if self.orientation() == Qt.Vertical: - x1 = xMap.transform(sample.interval.minValue()) - x2 = xMap.transform(sample.interval.maxValue()) - y = yMap.transform(sample.value) - if doAlign: - x1 = round(x1) - x2 = round(x2) - y = round(y) - if polygon.size() == 0: - polygon += QPointF(x1, v0) - polygon += QPointF(x1, y) - polygon += QPointF(x2, y) - else: - y1 = yMap.transform(sample.interval.minValue()) - y2 = yMap.transform(sample.interval.maxValue()) - x = xMap.transform(sample.value) - if doAlign: - y1 = round(y1) - y2 = round(y2) - x = round(x) - if polygon.size() == 0: - polygon += QPointF(v0, y1) - polygon += QPointF(x, y1) - polygon += QPointF(x, y2) - previous = sample - self.flushPolygon(painter, v0, polygon) - - def drawColumns(self, painter, xMap, yMap, from_, to): - painter.setPen(self.__data.pen) - painter.setBrush(self.__data.brush) - series = self.data() - for i in range(from_, to+1): - sample = series.sample(i) - if not sample.interval.isNull(): - rect = self.columnRect(sample, xMap, yMap) - self.drawColumn(painter, rect, sample) - - def drawLines(self, painter, xMap, yMap, from_, to): - doAlign = QwtPainter.roundingAlignment(painter) - painter.setPen(self.__data.pen) - painter.setBrush(self.__data.brush) - series = self.data() - for i in range(from_, to+1): - sample = series.sample(i) - if not sample.interval.isNull(): - rect = self.columnRect(sample, xMap, yMap) - r = QRectF(rect.toRect()) - if doAlign: - r.setLeft(round(r.left())) - r.setRight(round(r.right())) - r.setTop(round(r.top())) - r.setBottom(round(r.bottom())) - if rect.direction == QwtColumnRect.LeftToRight: - QwtPainter.drawLine(painter, r.topRight(), r.bottomRight()) - elif rect.direction == QwtColumnRect.RightToLeft: - QwtPainter.drawLine(painter, r.topLeft(), r.bottomLeft()) - elif rect.direction == QwtColumnRect.TopToBottom: - QwtPainter.drawLine(painter, r.bottomRight(), r.bottomLeft()) - elif rect.direction == QwtColumnRect.BottomToTop: - QwtPainter.drawLine(painter, r.topRight(), r.topLeft()) - - def flushPolygon(self, painter, baseline, polygon): - if polygon.size() == 0: - return - if self.orientation() == Qt.Horizontal: - polygon += QPointF(baseline, polygon[-1].y()) - else: - polygon += QPointF(polygon[-1].x(), baseline) - if self.__data.brush.style() != Qt.NoBrush: - painter.setPen(Qt.NoPen) - painter.setBrush(self.__data.brush) - if self.orientation() == Qt.Horizontal: - polygon += QPointF(polygon[-1].x(), baseline) - polygon += QPointF(polygon[0].x(), baseline) - else: - polygon += QPointF(baseline, polygon[-1].y()) - polygon += QPointF(baseline, polygon[0].y()) - QwtPainter.drawPolygon(painter, polygon) - polygon.pop(-1) - polygon.pop(-1) - if self.__data.pen.style != Qt.NoPen: - painter.setBrush(Qt.NoBrush) - painter.setPen(self.__data.pen) - QwtPainter.drawPolyline(painter, polygon) - polygon.clear() - - def columnRect(self, sample, xMap, yMap): - rect = QwtColumnRect() - iv = sample.interval - if not iv.isValid(): - return rect - if self.orientation() == Qt.Horizontal: - x0 = xMap.transform(self.baseline()) - x = xMap.transform(sample.value) - y1 = yMap.transform(iv.minValue()) - y2 = yMap.transform(iv.maxValue()) - rect.hInterval.setInterval(x0, x) - rect.vInterval.setInterval(y1, y2, iv.borderFlags()) - if x < x0: - rect.direction = QwtColumnRect.RightToLeft - else: - rect.direction = QwtColumnRect.LeftToRight - else: - x1 = xMap.transform(iv.minValue()) - x2 = xMap.transform(iv.maxValue()) - y0 = yMap.transform(self.baseline()) - y = yMap.transform(sample.value) - rect.hInterval.setInterval(x1, x2, iv.borderFlags()) - rect.vInterval.setInterval(y0, y) - if y < y0: - rect.direction = QwtColumnRect.BottomToTop - else: - rect.direction = QwtColumnRect.TopToBottom - return rect - - def drawColumn(self, painter, rect, sample): - if self.__data.symbol and\ - self.__data.symbol.style() != QwtColumnSymbol.NoStyle: - self.__data.symbol.draw(painter, rect) - else: - r = QRectF(rect.toRect()) - if QwtPainter.roundingAlignment(painter): - r.setLeft(round(r.left())) - r.setRight(round(r.right())) - r.setTop(round(r.top())) - r.setBottom(round(r.bottom())) - QwtPainter.drawRect(painter, r) - - def legendIcon(self, index, size): - return self.defaultIcon(self.__data.brush, size) diff --git a/qwt/plot_layout.py b/qwt/plot_layout.py index 57676d9..0919914 100644 --- a/qwt/plot_layout.py +++ b/qwt/plot_layout.py @@ -5,17 +5,25 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.text import QwtText -from qwt.scale_widget import QwtScaleWidget -from qwt.plot import QwtPlot -from qwt.scale_draw import QwtAbstractScaleDraw +""" +QwtPlotLayout +------------- + +.. autoclass:: QwtPlotLayout + :members: +""" -from qwt.qt.QtGui import QFont, QRegion -from qwt.qt.QtCore import QSize, Qt, QRectF +import math -import numpy as np +from qtpy.QtCore import QObject, QRectF, QSize, Qt +from qtpy.QtGui import QFont, QRegion + +from qwt.plot import QwtPlot +from qwt.scale_draw import QwtAbstractScaleDraw +from qwt.scale_widget import QwtScaleWidget +from qwt.text import QwtText -QWIDGETSIZE_MAX = (1<<24)-1 +QWIDGETSIZE_MAX = (1 << 24) - 1 class LegendData(object): @@ -25,16 +33,19 @@ def __init__(self): self.vScrollExtent = None self.hint = QSize() + class TitleData(object): def __init__(self): self.text = QwtText() self.frameWidth = None + class FooterData(object): def __init__(self): self.text = QwtText() self.frameWidth = None + class ScaleData(object): def __init__(self): self.isEnabled = None @@ -46,31 +57,33 @@ def __init__(self): self.tickOffset = None self.dimWithoutTitle = None + class CanvasData(object): def __init__(self): - self.contentsMargins = [0 for _i in range(QwtPlot.axisCnt)] + self.contentsMargins = [0 for _i in QwtPlot.AXES] + class QwtPlotLayout_LayoutData(object): def __init__(self): self.legend = LegendData() self.title = TitleData() self.footer = FooterData() - self.scale = [ScaleData() for _i in range(QwtPlot.axisCnt)] + self.scale = [ScaleData() for _i in QwtPlot.AXES] self.canvas = CanvasData() - + def init(self, plot, rect): + """Extract all layout relevant data from the plot components""" # legend - if plot.legend(): - self.legend.frameWidth = plot.legend().frameWidth() - self.legend.hScrollExtent = plot.legend().scrollExtent(Qt.Horizontal) - self.legend.vScrollExtent = plot.legend().scrollExtent(Qt.Vertical) - hint = plot.legend().sizeHint() - w = min([hint.width(), np.floor(rect.width())]) - h = plot.legend().heightForWidth(w) + legend = plot.legend() + if legend: + self.legend.frameWidth = legend.frameWidth() + self.legend.hScrollExtent = legend.scrollExtent(Qt.Horizontal) + self.legend.vScrollExtent = legend.scrollExtent(Qt.Vertical) + hint = legend.sizeHint() + w = min([hint.width(), math.floor(rect.width())]) + h = legend.heightForWidth(w) if h <= 0: h = hint.height() - if h > rect.height(): - w += self.legend.hScrollExtent self.legend.hint = QSize(w, h) # title self.title.frameWidth = 0 @@ -91,7 +104,7 @@ def init(self, plot, rect): self.footer.text.setFont(label.font()) self.footer.frameWidth = plot.footerLabel().frameWidth() # scales - for axis in range(QwtPlot.axisCnt): + for axis in QwtPlot.AXES: if plot.axisEnabled(axis): scaleWidget = plot.axisWidget(axis) self.scale[axis].isEnabled = True @@ -102,39 +115,73 @@ def init(self, plot, rect): self.scale[axis].baseLineOffset = scaleWidget.margin() self.scale[axis].tickOffset = scaleWidget.margin() if scaleWidget.scaleDraw().hasComponent(QwtAbstractScaleDraw.Ticks): - self.scale[axis].tickOffset += scaleWidget.scaleDraw().maxTickLength() + self.scale[ + axis + ].tickOffset += scaleWidget.scaleDraw().maxTickLength() self.scale[axis].dimWithoutTitle = scaleWidget.dimForLength( - QWIDGETSIZE_MAX, self.scale[axis].scaleFont) + QWIDGETSIZE_MAX, self.scale[axis].scaleFont + ) if not scaleWidget.title().isEmpty(): - self.scale[axis].dimWithoutTitle -= \ - scaleWidget.titleHeightForWidth(QWIDGETSIZE_MAX) + self.scale[axis].dimWithoutTitle -= scaleWidget.titleHeightForWidth( + QWIDGETSIZE_MAX + ) else: self.scale[axis].isEnabled = False self.scale[axis].start = 0 self.scale[axis].end = 0 self.scale[axis].baseLineOffset = 0 - self.scale[axis].tickOffset = 0. + self.scale[axis].tickOffset = 0.0 self.scale[axis].dimWithoutTitle = 0 - self.canvas.contentsMargins = plot.canvas().getContentsMargins() + layout = plot.canvas().layout() + if layout is not None: + mgn = layout.contentsMargins() + self.canvas.contentsMargins = [ + mgn.left(), + mgn.top(), + mgn.right(), + mgn.bottom(), + ] -class QwtPlotLayout_PrivateData(object): +class QwtPlotLayout_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.spacing = 5 self.titleRect = QRectF() self.footerRect = QRectF() self.legendRect = QRectF() - self.scaleRect = [QRectF() for _i in range(QwtPlot.axisCnt)] + self.scaleRect = [QRectF() for _i in QwtPlot.AXES] self.canvasRect = QRectF() self.layoutData = QwtPlotLayout_LayoutData() self.legendPos = None self.legendRatio = None - self.canvasMargin = [0] * QwtPlot.axisCnt - self.alignCanvasToScales = [False] * QwtPlot.axisCnt + self.canvasMargin = [0] * len(QwtPlot.AXES) + self.alignCanvasToScales = [False] * len(QwtPlot.AXES) class QwtPlotLayout(object): - + """ + Layout engine for QwtPlot. + + It is used by the `QwtPlot` widget to organize its internal widgets + or by `QwtPlot.print()` to render its content to a QPaintDevice like + a QPrinter, QPixmap/QImage or QSvgRenderer. + + .. seealso:: + + :py:meth:`qwt.plot.QwtPlot.setPlotLayout()` + + Valid options: + + * `QwtPlotLayout.AlignScales`: Unused + * `QwtPlotLayout.IgnoreScrollbars`: Ignore the dimension of the scrollbars. There are no scrollbars, when the plot is not rendered to widgets. + * `QwtPlotLayout.IgnoreFrames`: Ignore all frames. + * `QwtPlotLayout.IgnoreLegend`: Ignore the legend. + * `QwtPlotLayout.IgnoreTitle`: Ignore the title. + * `QwtPlotLayout.IgnoreFooter`: Ignore the footer. + """ + # enum Option AlignScales = 0x01 IgnoreScrollbars = 0x02 @@ -142,126 +189,381 @@ class QwtPlotLayout(object): IgnoreLegend = 0x08 IgnoreTitle = 0x10 IgnoreFooter = 0x20 - + def __init__(self): self.__data = QwtPlotLayout_PrivateData() self.setLegendPosition(QwtPlot.BottomLegend) self.setCanvasMargin(4) self.setAlignCanvasToScales(False) self.invalidate() - + def setCanvasMargin(self, margin, axis=-1): + """ + Change a margin of the canvas. The margin is the space + above/below the scale ticks. A negative margin will + be set to -1, excluding the borders of the scales. + + :param int margin: New margin + :param int axisId: Axis index + + .. seealso:: + + :py:meth:`canvasMargin()` + + .. warning:: + + The margin will have no effect when `alignCanvasToScale()` is True + """ if margin < 1: margin = -1 if axis == -1: - for axis in range(QwtPlot.axisCnt): + for axis in QwtPlot.AXES: self.__data.canvasMargin[axis] = margin - elif axis >= 0 and axis < QwtPlot.axisCnt: + elif axis in QwtPlot.AXES: self.__data.canvasMargin[axis] = margin - + def canvasMargin(self, axisId): - if axisId < 0 or axisId >= QwtPlot.axisCnt: + """ + :param int axisId: Axis index + :return: Margin around the scale tick borders + + .. seealso:: + + :py:meth:`setCanvasMargin()` + """ + if axisId not in QwtPlot.AXES: return 0 return self.__data.canvasMargin[axisId] - + def setAlignCanvasToScales(self, *args): + """ + Change the align-canvas-to-axis-scales setting. + + .. py:method:: setAlignCanvasToScales(on): + + Set the align-canvas-to-axis-scales flag for all axes + + :param bool on: True/False + + .. py:method:: setAlignCanvasToScales(axisId, on): + + Change the align-canvas-to-axis-scales setting. + The canvas may: + + - extend beyond the axis scale ends to maximize its size, + - align with the axis scale ends to control its size. + + The axisId parameter is somehow confusing as it identifies a + border of the plot and not the axes, that are aligned. F.e when + `QwtPlot.yLeft` is set, the left end of the the x-axes + (`QwtPlot.xTop`, `QwtPlot.xBottom`) is aligned. + + :param int axisId: Axis index + :param bool on: True/False + + .. seealso:: + + :py:meth:`setAlignCanvasToScale()`, + :py:meth:`alignCanvasToScale()` + """ if len(args) == 1: - on, = args - for axis in range(QwtPlot.axisCnt): + (on,) = args + for axis in QwtPlot.AXES: self.__data.alignCanvasToScales[axis] = on elif len(args) == 2: axisId, on = args - if axis >= 0 and axis < QwtPlot.axisCnt: + if axisId in QwtPlot.AXES: self.__data.alignCanvasToScales[axisId] = on else: - raise TypeError("%s().setAlignCanvasToScales() takes 1 or 2 "\ - "argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s().setAlignCanvasToScales() takes 1 or 2 " + "argument(s) (%s given)" % (self.__class__.__name__, len(args)) + ) def alignCanvasToScale(self, axisId): - if axisId < 0 or axisId >= QwtPlot.axisCnt: + """ + Return the align-canvas-to-axis-scales setting. + The canvas may: + + - extend beyond the axis scale ends to maximize its size + - align with the axis scale ends to control its size. + + :param int axisId: Axis index + :return: align-canvas-to-axis-scales setting + + .. seealso:: + + :py:meth:`setAlignCanvasToScale()`, :py:meth:`setCanvasMargin()` + """ + if axisId not in QwtPlot.AXES: return False return self.__data.alignCanvasToScales[axisId] - + def setSpacing(self, spacing): + """ + Change the spacing of the plot. The spacing is the distance + between the plot components. + + :param int spacing: New spacing + + .. seealso:: + + :py:meth:`setCanvasMargin()`, :py:meth:`spacing()` + """ self.__data.spacing = max([0, spacing]) - + def spacing(self): + """ + :return: Spacing + + .. seealso:: + + :py:meth:`margin()`, :py:meth:`setSpacing()` + """ return self.__data.spacing - + def setLegendPosition(self, *args): + """ + Specify the position of the legend + + .. py:method:: setLegendPosition(pos, [ratio=0.]): + + Specify the position of the legend + + :param QwtPlot.LegendPosition pos: Legend position + :param float ratio: Ratio between legend and the bounding rectangle of title, footer, canvas and axes + + The legend will be shrunk if it would need more space than the + given ratio. The ratio is limited to ]0.0 .. 1.0]. In case of + <= 0.0 it will be reset to the default ratio. The default + vertical/horizontal ratio is 0.33/0.5. + + Valid position values: + + * `QwtPlot.LeftLegend`, + * `QwtPlot.RightLegend`, + * `QwtPlot.TopLegend`, + * `QwtPlot.BottomLegend` + + .. seealso:: + + :py:meth:`setLegendPosition()` + """ if len(args) == 2: pos, ratio = args - if ratio > 1.: - ratio = 1. + if ratio > 1.0: + ratio = 1.0 if pos in (QwtPlot.TopLegend, QwtPlot.BottomLegend): - if ratio <= 0.: - ratio = .33 + if ratio <= 0.0: + ratio = 0.33 self.__data.legendRatio = ratio self.__data.legendPos = pos elif pos in (QwtPlot.LeftLegend, QwtPlot.RightLegend): - if ratio <= 0.: - ratio = .5 + if ratio <= 0.0: + ratio = 0.5 self.__data.legendRatio = ratio self.__data.legendPos = pos elif len(args) == 1: - pos, = args - self.setLegendPosition(pos, 0.) + (pos,) = args + self.setLegendPosition(pos, 0.0) else: - raise TypeError("%s().setLegendPosition() takes 1 or 2 argument(s)"\ - "(%s given)" % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s().setLegendPosition() takes 1 or 2 argument(s)" + "(%s given)" % (self.__class__.__name__, len(args)) + ) + def legendPosition(self): + """ + :return: Position of the legend + + .. seealso:: + + :py:meth:`legendPosition()` + """ return self.__data.legendPos - + def setLegendRatio(self, ratio): + """ + Specify the relative size of the legend in the plot + + :param float ratio: Ratio between legend and the bounding rectangle of title, footer, canvas and axes + + The legend will be shrunk if it would need more space than the + given ratio. The ratio is limited to ]0.0 .. 1.0]. In case of + <= 0.0 it will be reset to the default ratio. The default + vertical/horizontal ratio is 0.33/0.5. + + .. seealso:: + + :py:meth:`legendRatio()` + """ self.setLegendPosition(self.legendPosition(), ratio) - + def legendRatio(self): + """ + :return: The relative size of the legend in the plot. + + .. seealso:: + + :py:meth:`setLegendRatio()` + """ return self.__data.legendRatio - + def setTitleRect(self, rect): + """ + Set the geometry for the title + + This method is intended to be used from derived layouts + overloading `activate()` + + :param QRectF rect: Rectangle + + .. seealso:: + + :py:meth:`titleRect()`, :py:meth:`activate()` + """ self.__data.titleRect = rect - + def titleRect(self): + """ + :return: Geometry for the title + + .. seealso:: + + :py:meth:`invalidate()`, :py:meth:`activate()` + """ return self.__data.titleRect - + def setFooterRect(self, rect): + """ + Set the geometry for the footer + + This method is intended to be used from derived layouts + overloading `activate()` + + :param QRectF rect: Rectangle + + .. seealso:: + + :py:meth:`footerRect()`, :py:meth:`activate()` + """ self.__data.footerRect = rect - + def footerRect(self): + """ + :return: Geometry for the footer + + .. seealso:: + + :py:meth:`invalidate()`, :py:meth:`activate()` + """ return self.__data.footerRect - + def setLegendRect(self, rect): + """ + Set the geometry for the legend + + This method is intended to be used from derived layouts + overloading `activate()` + + :param QRectF rect: Rectangle for the legend + + .. seealso:: + + :py:meth:`footerRect()`, :py:meth:`activate()` + """ self.__data.legendRect = rect - + def legendRect(self): + """ + :return: Geometry for the legend + + .. seealso:: + + :py:meth:`invalidate()`, :py:meth:`activate()` + """ return self.__data.legendRect - + def setScaleRect(self, axis, rect): - if axis >= 0 and axis < QwtPlot.axisCnt: + """ + Set the geometry for an axis + + This method is intended to be used from derived layouts + overloading `activate()` + + :param int axisId: Axis index + :param QRectF rect: Rectangle for the scale + + .. seealso:: + + :py:meth:`scaleRect()`, :py:meth:`activate()` + """ + if axis in QwtPlot.AXES: self.__data.scaleRect[axis] = rect - + def scaleRect(self, axis): - if axis < 0 or axis >= QwtPlot.axisCnt: + """ + :param int axisId: Axis index + :return: Geometry for the scale + + .. seealso:: + + :py:meth:`invalidate()`, :py:meth:`activate()` + """ + if axis not in QwtPlot.AXES: return QRectF() return self.__data.scaleRect[axis] - + def setCanvasRect(self, rect): + """ + Set the geometry for the canvas + + This method is intended to be used from derived layouts + overloading `activate()` + + :param QRectF rect: Rectangle + + .. seealso:: + + :py:meth:`canvasRect()`, :py:meth:`activate()` + """ self.__data.canvasRect = rect - + def canvasRect(self): + """ + :return: Geometry for the canvas + + .. seealso:: + + :py:meth:`invalidate()`, :py:meth:`activate()` + """ return self.__data.canvasRect - + def invalidate(self): + """ + Invalidate the geometry of all components. + + .. seealso:: + + :py:meth:`activate()` + """ self.__data.titleRect = QRectF() self.__data.footerRect = QRectF() self.__data.legendRect = QRectF() self.__data.canvasRect = QRectF() - for axis in range(QwtPlot.axisCnt): + for axis in QwtPlot.AXES: self.__data.scaleRect[axis] = QRectF() - + def minimumSizeHint(self, plot): + """ + :param qwt.plot.QwtPlot plot: Plot widget + :return: Minimum size hint + + .. seealso:: + + :py:meth:`qwt.plot.QwtPlot.minimumSizeHint()` + """ + class _ScaleData(object): def __init__(self): self.w = 0 @@ -269,10 +571,21 @@ def __init__(self): self.minLeft = 0 self.minRight = 0 self.tickOffset = 0 - scaleData = [_ScaleData() for _i in range(QwtPlot.axisCnt)] - canvasBorder = [0 for _i in range(QwtPlot.axisCnt)] - fw, _, _, _ = plot.canvas().getContentsMargins() - for axis in range(QwtPlot.axisCnt): + + scaleData = [_ScaleData() for _i in QwtPlot.AXES] + canvasBorder = [0 for _i in QwtPlot.AXES] + layout = plot.canvas().layout() + if layout is None: + left, top, right, bottom = 0, 0, 0, 0 + else: + mgn = layout.contentsMargins() + left, top, right, bottom = ( + mgn.left(), + mgn.top(), + mgn.right(), + mgn.bottom(), + ) + for axis in QwtPlot.AXES: if plot.axisEnabled(axis): scl = plot.axisWidget(axis) sd = scaleData[axis] @@ -282,110 +595,139 @@ def __init__(self): sd.minLeft, sd.minLeft = scl.getBorderDistHint() sd.tickOffset = scl.margin() if scl.scaleDraw().hasComponent(QwtAbstractScaleDraw.Ticks): - sd.tickOffset += np.ceil(scl.scaleDraw().maxTickLength()) - canvasBorder[axis] = fw + self.__data.canvasMargin[axis] + 1 - for axis in range(QwtPlot.axisCnt): + sd.tickOffset += math.ceil(scl.scaleDraw().maxTickLength()) + canvasBorder[axis] = left + self.__data.canvasMargin[axis] + 1 + for axis in QwtPlot.AXES: sd = scaleData[axis] if sd.w and axis in (QwtPlot.xBottom, QwtPlot.xTop): - if sd.minLeft > canvasBorder[QwtPlot.yLeft]\ - and scaleData[QwtPlot.yLeft].w: + if ( + sd.minLeft > canvasBorder[QwtPlot.yLeft] + and scaleData[QwtPlot.yLeft].w + ): shiftLeft = sd.minLeft - canvasBorder[QwtPlot.yLeft] if shiftLeft > scaleData[QwtPlot.yLeft].w: shiftLeft = scaleData[QwtPlot.yLeft].w sd.w -= shiftLeft - if sd.minRight > canvasBorder[QwtPlot.yRight]\ - and scaleData[QwtPlot.yRight].w: + if ( + sd.minRight > canvasBorder[QwtPlot.yRight] + and scaleData[QwtPlot.yRight].w + ): shiftRight = sd.minRight - canvasBorder[QwtPlot.yRight] if shiftRight > scaleData[QwtPlot.yRight].w: shiftRight = scaleData[QwtPlot.yRight].w sd.w -= shiftRight if sd.h and axis in (QwtPlot.yLeft, QwtPlot.yRight): - if sd.minLeft > canvasBorder[QwtPlot.xBottom]\ - and scaleData[QwtPlot.xBottom].h: + if ( + sd.minLeft > canvasBorder[QwtPlot.xBottom] + and scaleData[QwtPlot.xBottom].h + ): shiftBottom = sd.minLeft - canvasBorder[QwtPlot.xBottom] if shiftBottom > scaleData[QwtPlot.xBottom].tickOffset: shiftBottom = scaleData[QwtPlot.xBottom].tickOffset sd.h -= shiftBottom - if sd.minLeft > canvasBorder[QwtPlot.xTop]\ - and scaleData[QwtPlot.xTop].h: + if ( + sd.minLeft > canvasBorder[QwtPlot.xTop] + and scaleData[QwtPlot.xTop].h + ): shiftTop = sd.minRight - canvasBorder[QwtPlot.xTop] if shiftTop > scaleData[QwtPlot.xTop].tickOffset: shiftTop = scaleData[QwtPlot.xTop].tickOffset sd.h -= shiftTop canvas = plot.canvas() - left, top, right, bottom = canvas.getContentsMargins() minCanvasSize = canvas.minimumSize() w = scaleData[QwtPlot.yLeft].w + scaleData[QwtPlot.yRight].w - cw = max([scaleData[QwtPlot.xBottom].w, - scaleData[QwtPlot.xTop].w]) + left + 1 + right + 1 + cw = ( + max([scaleData[QwtPlot.xBottom].w, scaleData[QwtPlot.xTop].w]) + + left + + 1 + + right + + 1 + ) w += max([cw, minCanvasSize.width()]) h = scaleData[QwtPlot.xBottom].h + scaleData[QwtPlot.xTop].h - ch = max([scaleData[QwtPlot.yLeft].h, - scaleData[QwtPlot.yRight].h]) + top + 1 + bottom + 1 + ch = ( + max([scaleData[QwtPlot.yLeft].h, scaleData[QwtPlot.yRight].h]) + + top + + 1 + + bottom + + 1 + ) h += max([ch, minCanvasSize.height()]) for label in [plot.titleLabel(), plot.footerLabel()]: if label and not label.text().isEmpty(): - centerOnCanvas = not plot.axisEnabled(QwtPlot.yLeft)\ - and plot.axisEnabled(QwtPlot.yRight) + centerOnCanvas = not plot.axisEnabled( + QwtPlot.yLeft + ) and plot.axisEnabled(QwtPlot.yRight) labelW = w if centerOnCanvas: - labelW -= scaleData[QwtPlot.yLeft].w +\ - scaleData[QwtPlot.yRight].w + labelW -= scaleData[QwtPlot.yLeft].w + scaleData[QwtPlot.yRight].w labelH = label.heightForWidth(labelW) if labelH > labelW: w = labelW = labelH if centerOnCanvas: - w += scaleData[QwtPlot.yLeft].w +\ - scaleData[QwtPlot.yRight].w + w += scaleData[QwtPlot.yLeft].w + scaleData[QwtPlot.yRight].w labelH = label.heightForWidth(labelW) h += labelH + self.__data.spacing legend = plot.legend() if legend and not legend.isEmpty(): - if self.__data.legendPos in (QwtPlot.LeftLegend, - QwtPlot.RightLegend): + if self.__data.legendPos in (QwtPlot.LeftLegend, QwtPlot.RightLegend): legendW = legend.sizeHint().width() legendH = legend.heightForWidth(legendW) if legend.frameWidth() > 0: w += self.__data.spacing if legendH > h: legendW += legend.scrollExtent(Qt.Horizontal) - if self.__data.legendRatio < 1.: - legendW = min([legendW, int(w/(1.-self.__data.legendRatio))]) + if self.__data.legendRatio < 1.0: + legendW = min([legendW, int(w / (1.0 - self.__data.legendRatio))]) w += legendW + self.__data.spacing else: legendW = min([legend.sizeHint().width(), w]) legendH = legend.heightForWidth(legendW) if legend.frameWidth() > 0: h += self.__data.spacing - if self.__data.legendRatio < 1.: - legendH = min([legendH, int(h/(1.-self.__data.legendRatio))]) + if self.__data.legendRatio < 1.0: + legendH = min([legendH, int(h / (1.0 - self.__data.legendRatio))]) h += legendH + self.__data.spacing - return QSize(w, h) - + return QSize(int(w), int(h)) + def layoutLegend(self, options, rect): + """ + Find the geometry for the legend + + :param options: Options how to layout the legend + :param QRectF rect: Rectangle where to place the legend + :return: Geometry for the legend + """ hint = self.__data.layoutData.legend.hint if self.__data.legendPos in (QwtPlot.LeftLegend, QwtPlot.RightLegend): - dim = min([hint.width(), int(rect.width()*self.__data.legendRatio)]) + dim = min([hint.width(), int(rect.width() * self.__data.legendRatio)]) if not (options & self.IgnoreScrollbars): if hint.height() > rect.height(): dim += self.__data.layoutData.legend.hScrollExtent else: - dim = min([hint.height(), int(rect.height()*self.__data.legendRatio)]) + dim = min([hint.height(), int(rect.height() * self.__data.legendRatio)]) dim = max([dim, self.__data.layoutData.legend.vScrollExtent]) legendRect = QRectF(rect) if self.__data.legendPos == QwtPlot.LeftLegend: legendRect.setWidth(dim) elif self.__data.legendPos == QwtPlot.RightLegend: - legendRect.setX(rect.right()-dim) + legendRect.setX(rect.right() - dim) legendRect.setWidth(dim) elif self.__data.legendPos == QwtPlot.TopLegend: legendRect.setHeight(dim) elif self.__data.legendPos == QwtPlot.BottomLegend: - legendRect.setY(rect.bottom()-dim) + legendRect.setY(rect.bottom() - dim) legendRect.setHeight(dim) return legendRect - + def alignLegend(self, canvasRect, legendRect): + """ + Align the legend to the canvas + + :param QRectF canvasRect: Geometry of the canvas + :param QRectF legendRect: Maximum geometry for the legend + :return: Geometry for the aligned legend + """ alignedRect = legendRect if self.__data.legendPos in (QwtPlot.BottomLegend, QwtPlot.TopLegend): if self.__data.layoutData.legend.hint.width() < canvasRect.width(): @@ -396,88 +738,162 @@ def alignLegend(self, canvasRect, legendRect): alignedRect.setY(canvasRect.y()) alignedRect.setHeight(canvasRect.height()) return alignedRect - + def expandLineBreaks(self, options, rect): + """ + Expand all line breaks in text labels, and calculate the height + of their widgets in orientation of the text. + + :param options: Options how to layout the legend + :param QRectF rect: Bounding rectangle for title, footer, axes and canvas. + :return: tuple `(dimTitle, dimFooter, dimAxes)` + + Returns: + + * `dimTitle`: Expanded height of the title widget + * `dimFooter`: Expanded height of the footer widget + * `dimAxes`: Expanded heights of the axis in axis orientation. + """ dimTitle = dimFooter = 0 - dimAxis = [0 for axis in range(QwtPlot.axisCnt)] - backboneOffset = [0 for _i in range(QwtPlot.axisCnt)] - for axis in range(QwtPlot.axisCnt): + dimAxes = [0 for axis in QwtPlot.AXES] + backboneOffset = [0 for _i in QwtPlot.AXES] + for axis in QwtPlot.AXES: if not (options & self.IgnoreFrames): - backboneOffset[axis] += self.__data.layoutData.canvas.contentsMargins[axis] + backboneOffset[axis] += self.__data.layoutData.canvas.contentsMargins[ + axis + ] if not self.__data.alignCanvasToScales[axis]: backboneOffset[axis] += self.__data.canvasMargin[axis] done = False while not done: done = True - if not ((options & self.IgnoreTitle) or \ - self.__data.layoutData.title.text.isEmpty()): + # the size for the 4 axis depend on each other. Expanding + # the height of a horizontal axis will shrink the height + # for the vertical axis, shrinking the height of a vertical + # axis will result in a line break what will expand the + # width and results in shrinking the width of a horizontal + # axis what might result in a line break of a horizontal + # axis ... . So we loop as long until no size changes. + if not ( + (options & self.IgnoreTitle) + or self.__data.layoutData.title.text.isEmpty() + ): w = rect.width() - if self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled !=\ - self.__data.layoutData.scale[QwtPlot.yRight].isEnabled: - w -= dimAxis[QwtPlot.yLeft]+dimAxis[QwtPlot.yRight] - d = np.ceil(self.__data.layoutData.title.text.heightForWidth(w)) + if ( + self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled + != self.__data.layoutData.scale[QwtPlot.yRight].isEnabled + ): + w -= dimAxes[QwtPlot.yLeft] + dimAxes[QwtPlot.yRight] + d = math.ceil(self.__data.layoutData.title.text.heightForWidth(w)) if not (options & self.IgnoreFrames): - d += 2*self.__data.layoutData.title.frameWidth + d += 2 * self.__data.layoutData.title.frameWidth if d > dimTitle: dimTitle = d done = False - if not ((options & self.IgnoreFooter) or \ - self.__data.layoutData.footer.text.isEmpty()): + if not ( + (options & self.IgnoreFooter) + or self.__data.layoutData.footer.text.isEmpty() + ): w = rect.width() - if self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled !=\ - self.__data.layoutData.scale[QwtPlot.yRight].isEnabled: - w -= dimAxis[QwtPlot.yLeft]+dimAxis[QwtPlot.yRight] - d = np.ceil(self.__data.layoutData.footer.text.heightForWidth(w)) + if ( + self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled + != self.__data.layoutData.scale[QwtPlot.yRight].isEnabled + ): + w -= dimAxes[QwtPlot.yLeft] + dimAxes[QwtPlot.yRight] + d = math.ceil(self.__data.layoutData.footer.text.heightForWidth(w)) if not (options & self.IgnoreFrames): - d += 2*self.__data.layoutData.footer.frameWidth + d += 2 * self.__data.layoutData.footer.frameWidth if d > dimFooter: dimFooter = d done = False - for axis in range(QwtPlot.axisCnt): + for axis in QwtPlot.AXES: scaleData = self.__data.layoutData.scale[axis] if scaleData.isEnabled: if axis in (QwtPlot.xTop, QwtPlot.xBottom): - length = rect.width()-dimAxis[QwtPlot.yLeft]-dimAxis[QwtPlot.yRight] + length = ( + rect.width() + - dimAxes[QwtPlot.yLeft] + - dimAxes[QwtPlot.yRight] + ) length -= scaleData.start + scaleData.end - if dimAxis[QwtPlot.yRight] > 0: + if dimAxes[QwtPlot.yRight] > 0: length -= 1 - length += min([dimAxis[QwtPlot.yLeft], - scaleData.start-backboneOffset[QwtPlot.yLeft]]) - length += min([dimAxis[QwtPlot.yRight], - scaleData.end-backboneOffset[QwtPlot.yRight]]) + length += min( + [ + dimAxes[QwtPlot.yLeft], + scaleData.start - backboneOffset[QwtPlot.yLeft], + ] + ) + length += min( + [ + dimAxes[QwtPlot.yRight], + scaleData.end - backboneOffset[QwtPlot.yRight], + ] + ) else: - length = rect.height()-dimAxis[QwtPlot.xTop]-dimAxis[QwtPlot.xBottom] + length = ( + rect.height() + - dimAxes[QwtPlot.xTop] + - dimAxes[QwtPlot.xBottom] + ) length -= scaleData.start + scaleData.end length -= 1 - if dimAxis[QwtPlot.xBottom] <= 0: + if dimAxes[QwtPlot.xBottom] <= 0: length -= 1 - if dimAxis[QwtPlot.xTop] <= 0: + if dimAxes[QwtPlot.xTop] <= 0: length -= 1 - if dimAxis[QwtPlot.xBottom] > 0: - length += min([self.__data.layoutData.scale[QwtPlot.xBottom].tickOffset, - float(scaleData.start-backboneOffset[QwtPlot.xBottom])]) - if dimAxis[QwtPlot.xTop] > 0: - length += min([self.__data.layoutData.scale[QwtPlot.xTop].tickOffset, - float(scaleData.end-backboneOffset[QwtPlot.xTop])]) + if dimAxes[QwtPlot.xBottom] > 0: + length += min( + [ + self.__data.layoutData.scale[ + QwtPlot.xBottom + ].tickOffset, + float( + scaleData.start + - backboneOffset[QwtPlot.xBottom] + ), + ] + ) + if dimAxes[QwtPlot.xTop] > 0: + length += min( + [ + self.__data.layoutData.scale[ + QwtPlot.xTop + ].tickOffset, + float(scaleData.end - backboneOffset[QwtPlot.xTop]), + ] + ) if dimTitle > 0: length -= dimTitle + self.__data.spacing d = scaleData.dimWithoutTitle if not scaleData.scaleWidget.title().isEmpty(): - d += scaleData.scaleWidget.titleHeightForWidth(np.floor(length)) - if d > dimAxis[axis]: - dimAxis[axis] = d + d += scaleData.scaleWidget.titleHeightForWidth( + math.floor(length) + ) + if d > dimAxes[axis]: + dimAxes[axis] = d done = False - return dimTitle, dimFooter, dimAxis - + return dimTitle, dimFooter, dimAxes + def alignScales(self, options, canvasRect, scaleRect): - backboneOffset = [0 for _i in range(QwtPlot.axisCnt)] - for axis in range(QwtPlot.axisCnt): + """ + Align the ticks of the axis to the canvas borders using + the empty corners. + + :param options: Options how to layout the legend + :param QRectF canvasRect: Geometry of the canvas ( IN/OUT ) + :param QRectF scaleRect: Geometry of the scales ( IN/OUT ) + """ + backboneOffset = [0 for _i in QwtPlot.AXES] + for axis in QwtPlot.AXES: backboneOffset[axis] = 0 if not self.__data.alignCanvasToScales[axis]: backboneOffset[axis] += self.__data.canvasMargin[axis] if not options & self.IgnoreFrames: - backboneOffset[axis] += self.__data.layoutData.canvas.contentsMargins[axis] - for axis in range(QwtPlot.axisCnt): + backboneOffset[axis] += self.__data.layoutData.canvas.contentsMargins[ + axis + ] + for axis in QwtPlot.AXES: if not scaleRect[axis].isValid(): continue startDist = self.__data.layoutData.scale[axis].start @@ -485,93 +901,114 @@ def alignScales(self, options, canvasRect, scaleRect): axisRect = scaleRect[axis] if axis in (QwtPlot.xTop, QwtPlot.xBottom): leftScaleRect = scaleRect[QwtPlot.yLeft] - leftOffset = backboneOffset[QwtPlot.yLeft]-startDist + leftOffset = backboneOffset[QwtPlot.yLeft] - startDist if leftScaleRect.isValid(): dx = leftOffset + leftScaleRect.width() - if self.__data.alignCanvasToScales[QwtPlot.yLeft] and dx < 0.: + if self.__data.alignCanvasToScales[QwtPlot.yLeft] and dx < 0.0: cLeft = canvasRect.left() - canvasRect.setLeft(max([cLeft, axisRect.left()-dx])) + canvasRect.setLeft(max([cLeft, axisRect.left() - dx])) else: minLeft = leftScaleRect.left() - left = axisRect.left()+leftOffset + left = axisRect.left() + leftOffset axisRect.setLeft(max([left, minLeft])) else: - if self.__data.alignCanvasToScales[QwtPlot.yLeft] and leftOffset < 0: - canvasRect.setLeft(max([canvasRect.left(), - axisRect.left()-leftOffset])) + if ( + self.__data.alignCanvasToScales[QwtPlot.yLeft] + and leftOffset < 0 + ): + canvasRect.setLeft( + max([canvasRect.left(), axisRect.left() - leftOffset]) + ) else: if leftOffset > 0: - axisRect.setLeft(axisRect.left()+leftOffset) + axisRect.setLeft(axisRect.left() + leftOffset) rightScaleRect = scaleRect[QwtPlot.yRight] - rightOffset = backboneOffset[QwtPlot.yRight]-endDist+1 + rightOffset = backboneOffset[QwtPlot.yRight] - endDist + 1 if rightScaleRect.isValid(): - dx = rightOffset+rightScaleRect.width() + dx = rightOffset + rightScaleRect.width() if self.__data.alignCanvasToScales[QwtPlot.yRight] and dx < 0: cRight = canvasRect.right() - canvasRect.setRight(min([cRight, axisRect.right()+dx])) + canvasRect.setRight(min([cRight, axisRect.right() + dx])) maxRight = rightScaleRect.right() - right = axisRect.right()-rightOffset + right = axisRect.right() - rightOffset axisRect.setRight(min([right, maxRight])) else: - if self.__data.alignCanvasToScales[QwtPlot.yRight] and rightOffset < 0: - canvasRect.setRight(min([canvasRect.right(), - axisRect.right()+rightOffset])) + if ( + self.__data.alignCanvasToScales[QwtPlot.yRight] + and rightOffset < 0 + ): + canvasRect.setRight( + min([canvasRect.right(), axisRect.right() + rightOffset]) + ) else: if rightOffset > 0: - axisRect.setRight(axisRect.right()-rightOffset) + axisRect.setRight(axisRect.right() - rightOffset) else: bottomScaleRect = scaleRect[QwtPlot.xBottom] - bottomOffset = backboneOffset[QwtPlot.xBottom]-endDist+1 + bottomOffset = backboneOffset[QwtPlot.xBottom] - endDist + 1 if bottomScaleRect.isValid(): - dy = bottomOffset+bottomScaleRect.height() + dy = bottomOffset + bottomScaleRect.height() if self.__data.alignCanvasToScales[QwtPlot.xBottom] and dy < 0: cBottom = canvasRect.bottom() - canvasRect.setBottom(min([cBottom, axisRect.bottom()+dy])) + canvasRect.setBottom(min([cBottom, axisRect.bottom() + dy])) else: - maxBottom = bottomScaleRect.top()+\ - self.__data.layoutData.scale[QwtPlot.xBottom].tickOffset - bottom = axisRect.bottom()-bottomOffset + maxBottom = ( + bottomScaleRect.top() + + self.__data.layoutData.scale[QwtPlot.xBottom].tickOffset + ) + bottom = axisRect.bottom() - bottomOffset axisRect.setBottom(min([bottom, maxBottom])) else: - if self.__data.alignCanvasToScales[QwtPlot.xBottom] and bottomOffset < 0: - canvasRect.setBottom(min([canvasRect.bottom(), - axisRect.bottom()+bottomOffset])) + if ( + self.__data.alignCanvasToScales[QwtPlot.xBottom] + and bottomOffset < 0 + ): + canvasRect.setBottom( + min([canvasRect.bottom(), axisRect.bottom() + bottomOffset]) + ) else: if bottomOffset > 0: - axisRect.setBottom(axisRect.bottom()-bottomOffset) + axisRect.setBottom(axisRect.bottom() - bottomOffset) topScaleRect = scaleRect[QwtPlot.xTop] - topOffset = backboneOffset[QwtPlot.xTop]-startDist + topOffset = backboneOffset[QwtPlot.xTop] - startDist if topScaleRect.isValid(): - dy = topOffset+topScaleRect.height() + dy = topOffset + topScaleRect.height() if self.__data.alignCanvasToScales[QwtPlot.xTop] and dy < 0: cTop = canvasRect.top() - canvasRect.setTop(max([cTop, axisRect.top()-dy])) + canvasRect.setTop(max([cTop, axisRect.top() - dy])) else: - minTop = topScaleRect.bottom()-\ - self.__data.layoutData.scale[QwtPlot.xTop].tickOffset - top = axisRect.top()+topOffset + minTop = ( + topScaleRect.bottom() + - self.__data.layoutData.scale[QwtPlot.xTop].tickOffset + ) + top = axisRect.top() + topOffset axisRect.setTop(max([top, minTop])) else: if self.__data.alignCanvasToScales[QwtPlot.xTop] and topOffset < 0: - canvasRect.setTop(max([canvasRect.top(), - axisRect.top()-topOffset])) + canvasRect.setTop( + max([canvasRect.top(), axisRect.top() - topOffset]) + ) else: if topOffset > 0: - axisRect.setTop(axisRect.top()+topOffset) - for axis in range(QwtPlot.axisCnt): + axisRect.setTop(axisRect.top() + topOffset) + for axis in QwtPlot.AXES: sRect = scaleRect[axis] if not sRect.isValid(): continue if axis in (QwtPlot.xBottom, QwtPlot.xTop): if self.__data.alignCanvasToScales[QwtPlot.yLeft]: - y = canvasRect.left()-self.__data.layoutData.scale[axis].start + y = canvasRect.left() - self.__data.layoutData.scale[axis].start if not options & self.IgnoreFrames: - y += self.__data.layoutData.canvas.contentsMargins[QwtPlot.yLeft] + y += self.__data.layoutData.canvas.contentsMargins[ + QwtPlot.yLeft + ] sRect.setLeft(y) if self.__data.alignCanvasToScales[QwtPlot.yRight]: - y = canvasRect.right()-1+self.__data.layoutData.scale[axis].end + y = canvasRect.right() - 1 + self.__data.layoutData.scale[axis].end if not options & self.IgnoreFrames: - y -= self.__data.layoutData.canvas.contentsMargins[QwtPlot.yRight] + y -= self.__data.layoutData.canvas.contentsMargins[ + QwtPlot.yRight + ] sRect.setRight(y) if self.__data.alignCanvasToScales[axis]: if axis == QwtPlot.xTop: @@ -580,86 +1017,117 @@ def alignScales(self, options, canvasRect, scaleRect): sRect.setTop(canvasRect.bottom()) else: if self.__data.alignCanvasToScales[QwtPlot.xTop]: - x = canvasRect.top()-self.__data.layoutData.scale[axis].start + x = canvasRect.top() - self.__data.layoutData.scale[axis].start if not options & self.IgnoreFrames: x += self.__data.layoutData.canvas.contentsMargins[QwtPlot.xTop] sRect.setTop(x) if self.__data.alignCanvasToScales[QwtPlot.xBottom]: - x = canvasRect.bottom()-1+self.__data.layoutData.scale[axis].end + x = canvasRect.bottom() - 1 + self.__data.layoutData.scale[axis].end if not options & self.IgnoreFrames: - x -= self.__data.layoutData.canvas.contentsMargins[QwtPlot.xBottom] + x -= self.__data.layoutData.canvas.contentsMargins[ + QwtPlot.xBottom + ] sRect.setBottom(x) if self.__data.alignCanvasToScales[axis]: if axis == QwtPlot.yLeft: sRect.setRight(canvasRect.left()) else: sRect.setLeft(canvasRect.right()) - + def activate(self, plot, plotRect, options=0x00): + """ + Recalculate the geometry of all components. + + :param qwt.plot.QwtPlot plot: Plot to be layout + :param QRectF plotRect: Rectangle where to place the components + :param options: Layout options + """ self.invalidate() rect = QRectF(plotRect) self.__data.layoutData.init(plot, rect) - if not (options & self.IgnoreLegend) and plot.legend() and\ - not plot.legend().isEmpty(): + if ( + not (options & self.IgnoreLegend) + and plot.legend() + and not plot.legend().isEmpty() + ): self.__data.legendRect = self.layoutLegend(options, rect) region = QRegion(rect.toRect()) - rect = region.subtracted(QRegion(self.__data.legendRect.toRect()) - ).boundingRect() + rect = QRectF( + region.subtracted( + QRegion(self.__data.legendRect.toRect()) + ).boundingRect() + ) if self.__data.legendPos == QwtPlot.LeftLegend: - rect.setLeft(rect.left()+self.__data.spacing) + rect.setLeft(rect.left() + self.__data.spacing) elif self.__data.legendPos == QwtPlot.RightLegend: - rect.setRight(rect.right()-self.__data.spacing) + rect.setRight(rect.right() - self.__data.spacing) elif self.__data.legendPos == QwtPlot.TopLegend: - rect.setTop(rect.top()+self.__data.spacing) + rect.setTop(rect.top() + self.__data.spacing) elif self.__data.legendPos == QwtPlot.BottomLegend: - rect.setBottom(rect.bottom()-self.__data.spacing) - -# +---+-----------+---+ -# | Title | -# +---+-----------+---+ -# | | Axis | | -# +---+-----------+---+ -# | A | | A | -# | x | Canvas | x | -# | i | | i | -# | s | | s | -# +---+-----------+---+ -# | | Axis | | -# +---+-----------+---+ -# | Footer | -# +---+-----------+---+ + rect.setBottom(rect.bottom() - self.__data.spacing) + + # +---+-----------+---+ + # | Title | + # +---+-----------+---+ + # | | Axis | | + # +---+-----------+---+ + # | A | | A | + # | x | Canvas | x | + # | i | | i | + # | s | | s | + # +---+-----------+---+ + # | | Axis | | + # +---+-----------+---+ + # | Footer | + # +---+-----------+---+ + + # title, footer and axes include text labels. The height of each + # label depends on its line breaks, that depend on the width + # for the label. A line break in a horizontal text will reduce + # the available width for vertical texts and vice versa. + # expandLineBreaks finds the height/width for title, footer and axes + # including all line breaks. dimTitle, dimFooter, dimAxes = self.expandLineBreaks(options, rect) if dimTitle > 0: - self.__data.titleRect.setRect(rect.left(), rect.top(), - rect.width(), dimTitle) - rect.setTop(self.__data.titleRect.bottom()+self.__data.spacing) - if self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled !=\ - self.__data.layoutData.scale[QwtPlot.yRight].isEnabled: - self.__data.titleRect.setX(rect.left()+dimAxes[QwtPlot.yLeft]) - self.__data.titleRect.setWidth(rect.width()\ - -dimAxes[QwtPlot.yLeft]-dimAxes[QwtPlot.yRight]) + self.__data.titleRect.setRect( + rect.left(), rect.top(), rect.width(), dimTitle + ) + rect.setTop(self.__data.titleRect.bottom() + self.__data.spacing) + if ( + self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled + != self.__data.layoutData.scale[QwtPlot.yRight].isEnabled + ): + self.__data.titleRect.setX(rect.left() + dimAxes[QwtPlot.yLeft]) + self.__data.titleRect.setWidth( + rect.width() - dimAxes[QwtPlot.yLeft] - dimAxes[QwtPlot.yRight] + ) if dimFooter > 0: - self.__data.footerRect.setRect(rect.left(), - rect.bottom()-dimFooter, rect.width(), dimFooter) - rect.setBottom(self.__data.footerRect.top()-self.__data.spacing) - if self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled !=\ - self.__data.layoutData.scale[QwtPlot.yRight].isEnabled: - self.__data.footerRect.setX(rect.left()+dimAxes[QwtPlot.yLeft]) - self.__data.footerRect.setWidth(rect.width()\ - -dimAxes[QwtPlot.yLeft]-dimAxes[QwtPlot.yRight]) + self.__data.footerRect.setRect( + rect.left(), rect.bottom() - dimFooter, rect.width(), dimFooter + ) + rect.setBottom(self.__data.footerRect.top() - self.__data.spacing) + if ( + self.__data.layoutData.scale[QwtPlot.yLeft].isEnabled + != self.__data.layoutData.scale[QwtPlot.yRight].isEnabled + ): + self.__data.footerRect.setX(rect.left() + dimAxes[QwtPlot.yLeft]) + self.__data.footerRect.setWidth( + rect.width() - dimAxes[QwtPlot.yLeft] - dimAxes[QwtPlot.yRight] + ) self.__data.canvasRect.setRect( - rect.x()+dimAxes[QwtPlot.yLeft], - rect.y()+dimAxes[QwtPlot.xTop], - rect.width()-dimAxes[QwtPlot.yRight]-dimAxes[QwtPlot.yLeft], - rect.height()-dimAxes[QwtPlot.xBottom]-dimAxes[QwtPlot.xTop]) - for axis in range(QwtPlot.axisCnt): + rect.x() + dimAxes[QwtPlot.yLeft], + rect.y() + dimAxes[QwtPlot.xTop], + rect.width() - dimAxes[QwtPlot.yRight] - dimAxes[QwtPlot.yLeft], + rect.height() - dimAxes[QwtPlot.xBottom] - dimAxes[QwtPlot.xTop], + ) + for axis in QwtPlot.AXES: if dimAxes[axis]: dim = dimAxes[axis] scaleRect = self.__data.scaleRect[axis] scaleRect.setRect(*self.__data.canvasRect.getRect()) if axis == QwtPlot.yLeft: - scaleRect.setX(self.__data.canvasRect.left()-dim) + scaleRect.setX(self.__data.canvasRect.left() - dim) scaleRect.setWidth(dim) elif axis == QwtPlot.yRight: scaleRect.setX(self.__data.canvasRect.right()) @@ -668,27 +1136,32 @@ def activate(self, plot, plotRect, options=0x00): scaleRect.setY(self.__data.canvasRect.bottom()) scaleRect.setHeight(dim) elif axis == QwtPlot.xTop: - scaleRect.setY(self.__data.canvasRect.top()-dim) + scaleRect.setY(self.__data.canvasRect.top() - dim) scaleRect.setHeight(dim) scaleRect = scaleRect.normalized() - -# +---+-----------+---+ -# | <- Axis -> | -# +-^-+-----------+-^-+ -# | | | | | | -# | | | | -# | A | | A | -# | x | Canvas | x | -# | i | | i | -# | s | | s | -# | | | | -# | | | | | | -# +-V-+-----------+-V-+ -# | <- Axis -> | -# +---+-----------+---+ - - self.alignScales(options, self.__data.canvasRect, - self.__data.scaleRect) + + # +---+-----------+---+ + # | <- Axis -> | + # +-^-+-----------+-^-+ + # | | | | | | + # | | | | + # | A | | A | + # | x | Canvas | x | + # | i | | i | + # | s | | s | + # | | | | + # | | | | | | + # +-V-+-----------+-V-+ + # | <- Axis -> | + # +---+-----------+---+ + + # The ticks of the axes - not the labels above - should + # be aligned to the canvas. So we try to use the empty + # corners to extend the axes, so that the label texts + # left/right of the min/max ticks are moved into them. + + self.alignScales(options, self.__data.canvasRect, self.__data.scaleRect) if not self.__data.legendRect.isEmpty(): - self.__data.legendRect = self.alignLegend(self.__data.canvasRect, - self.__data.legendRect) + self.__data.legendRect = self.alignLegend( + self.__data.canvasRect, self.__data.legendRect + ) diff --git a/qwt/plot_marker.py b/qwt/plot_marker.py index 87e05e4..1db25cd 100644 --- a/qwt/plot_marker.py +++ b/qwt/plot_marker.py @@ -5,35 +5,70 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.plot import QwtPlotItem -from qwt.text import QwtText -from qwt.painter import QwtPainter +""" +QwtPlotMarker +------------- + +.. autoclass:: QwtPlotMarker + :members: +""" + +from qtpy.QtCore import QLineF, QObject, QPointF, QRect, QRectF, QSizeF, Qt +from qtpy.QtGui import QPainter, QPen + from qwt.graphic import QwtGraphic +from qwt.plot import QwtPlot, QwtPlotItem +from qwt.qthelpers import qcolor_from_str from qwt.symbol import QwtSymbol - -from qwt.qt.QtGui import QPen, QPainter -from qwt.qt.QtCore import Qt, QPointF, QRectF, QSizeF, QRect +from qwt.text import QwtText -class QwtPlotMarker_PrivateData(object): +class QwtPlotMarker_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.labelAlignment = Qt.AlignCenter self.labelOrientation = Qt.Horizontal self.spacing = 2 self.symbol = None self.style = QwtPlotMarker.NoLine - self.xValue = 0. - self.yValue = 0. + self.xValue = 0.0 + self.yValue = 0.0 self.label = QwtText() self.pen = QPen() class QwtPlotMarker(QwtPlotItem): - + """ + A class for drawing markers + + A marker can be a horizontal line, a vertical line, + a symbol, a label or any combination of them, which can + be drawn around a center point inside a bounding rectangle. + + The `setSymbol()` member assigns a symbol to the marker. + The symbol is drawn at the specified point. + + With `setLabel()`, a label can be assigned to the marker. + The `setLabelAlignment()` member specifies where the label is drawn. All + the Align*-constants in `Qt.AlignmentFlags` (see Qt documentation) + are valid. The interpretation of the alignment depends on the marker's + line style. The alignment refers to the center point of + the marker, which means, for example, that the label would be printed + left above the center point if the alignment was set to + `Qt.AlignLeft | Qt.AlignTop`. + + Line styles: + + * `QwtPlotMarker.NoLine`: No line + * `QwtPlotMarker.HLine`: A horizontal line + * `QwtPlotMarker.VLine`: A vertical line + * `QwtPlotMarker.Cross`: A crosshair + """ + # enum LineStyle NoLine, HLine, VLine, Cross = list(range(4)) - - + def __init__(self, title=None): if title is None: title = "" @@ -41,23 +76,127 @@ def __init__(self, title=None): title = QwtText(title) QwtPlotItem.__init__(self, title) self.__data = QwtPlotMarker_PrivateData() - self.setZ(30.) - + self.setZ(30.0) + + @classmethod + def make( + cls, + xvalue=None, + yvalue=None, + title=None, + label=None, + symbol=None, + plot=None, + z=None, + x_axis=None, + y_axis=None, + align=None, + orientation=None, + spacing=None, + linestyle=None, + color=None, + width=None, + style=None, + antialiased=False, + ): + """ + Create and setup a new `QwtPlotMarker` object (convenience function). + + :param xvalue: x position (optional, default: None) + :type xvalue: float or None + :param yvalue: y position (optional, default: None) + :type yvalue: float or None + :param title: Marker title + :type title: qwt.text.QwtText or str or None + :param label: Label text + :type label: qwt.text.QwtText or str or None + :param symbol: New symbol + :type symbol: qwt.symbol.QwtSymbol or None + :param plot: Plot to attach the curve to + :type plot: qwt.plot.QwtPlot or None + :param z: Z-value + :type z: float or None + :param int x_axis: curve X-axis (default: QwtPlot.yLeft) + :param int y_axis: curve Y-axis (default: QwtPlot.xBottom) + :param align: Alignment of the label + :type align: Qt.Alignment or None + :param orientation: Orientation of the label + :type orientation: Qt.Orientation or None + :param spacing: Spacing (distance between the position and the label) + :type spacing: int or None + :param int linestyle: Line style + :param color: Pen color + :type color: QColor or str or None + :param float width: Pen width + :param Qt.PenStyle style: Pen style + :param bool antialiased: if True, enable antialiasing rendering + + .. seealso:: + + :py:meth:`setData()`, :py:meth:`setPen()`, :py:meth:`attach()` + """ + item = cls(title) + if z is not None: + item.setZ(z) + if symbol is not None: + item.setSymbol(symbol) + if xvalue is not None: + item.setXValue(xvalue) + if yvalue is not None: + item.setYValue(yvalue) + if label is not None: + item.setLabel(label) + x_axis = QwtPlot.xBottom if x_axis is None else x_axis + y_axis = QwtPlot.yLeft if y_axis is None else y_axis + item.setAxes(x_axis, y_axis) + if align is not None: + item.setLabelAlignment(align) + if orientation is not None: + item.setLabelOrientation(orientation) + if spacing is not None: + item.setSpacing(spacing) + color = qcolor_from_str(color, Qt.black) + width = 1.0 if width is None else width + style = Qt.SolidLine if style is None else style + item.setLinePen(QPen(color, width, style)) + item.setRenderHint(cls.RenderAntialiased, antialiased) + if linestyle is not None: + item.setLineStyle(linestyle) + if plot is not None: + item.attach(plot) + return item + def rtti(self): + """:return: `QwtPlotItem.Rtti_PlotMarker`""" return QwtPlotItem.Rtti_PlotMarker - + def value(self): + """:return: Value""" return QPointF(self.__data.xValue, self.__data.yValue) - + def xValue(self): + """:return: x Value""" return self.__data.xValue - + def yValue(self): + """:return: y Value""" return self.__data.yValue - + def setValue(self, *args): + """ + Set Value + + .. py:method:: setValue(pos): + + :param QPointF pos: Position + + .. py:method:: setValue(x, y): + + :param float x: x position + :param float y: y position + """ if len(args) == 1: - pos, = args + (pos,) = args self.setValue(pos.x(), pos.y()) elif len(args) == 2: x, y = args @@ -66,59 +205,100 @@ def setValue(self, *args): self.__data.yValue = y self.itemChanged() else: - raise TypeError("%s() takes 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s() takes 1 or 2 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) def setXValue(self, x): + """ + Set X Value + + :param float x: x position + """ self.setValue(x, self.__data.yValue) - + def setYValue(self, y): + """ + Set Y Value + + :param float y: y position + """ self.setValue(self.__data.xValue, y) - + def draw(self, painter, xMap, yMap, canvasRect): - pos = QPointF(xMap.transform(self.__data.xValue), - yMap.transform(self.__data.yValue)) + """ + Draw the marker + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: x Scale Map + :param qwt.scale_map.QwtScaleMap yMap: y Scale Map + :param QRectF canvasRect: Contents rectangle of the canvas in painter coordinates + """ + pos = QPointF( + xMap.transform(self.__data.xValue), yMap.transform(self.__data.yValue) + ) self.drawLines(painter, canvasRect, pos) - if self.__data.symbol and\ - self.__data.symbol.style() != QwtSymbol.NoSymbol: + if self.__data.symbol and self.__data.symbol.style() != QwtSymbol.NoSymbol: sz = self.__data.symbol.size() - clipRect = QRectF(canvasRect.adjusted(-sz.width(), -sz.height(), - sz.width(), sz.height())) + width, height = int(sz.width()), int(sz.height()) + clipRect = QRectF(canvasRect.adjusted(-width, -height, width, height)) if clipRect.contains(pos): self.__data.symbol.drawSymbols(painter, [pos]) self.drawLabel(painter, canvasRect, pos) - + def drawLines(self, painter, canvasRect, pos): + """ + Draw the lines marker + + :param QPainter painter: Painter + :param QRectF canvasRect: Contents rectangle of the canvas in painter coordinates + :param QPointF pos: Position of the marker, translated into widget coordinates + + .. seealso:: + + :py:meth:`drawLabel()`, + :py:meth:`qwt.symbol.QwtSymbol.drawSymbol()` + """ if self.__data.style == self.NoLine: return - doAlign = QwtPainter.roundingAlignment(painter) painter.setPen(self.__data.pen) if self.__data.style in (QwtPlotMarker.HLine, QwtPlotMarker.Cross): y = pos.y() - if doAlign: - y = round(y) - QwtPainter.drawLine(painter, canvasRect.left(), - y, canvasRect.right()-1., y) + painter.drawLine(QLineF(canvasRect.left(), y, canvasRect.right() - 1.0, y)) if self.__data.style in (QwtPlotMarker.VLine, QwtPlotMarker.Cross): x = pos.x() - if doAlign: - x = round(x) - QwtPainter.drawLine(painter, x, - canvasRect.top(), x, canvasRect.bottom()-1.) - + painter.drawLine(QLineF(x, canvasRect.top(), x, canvasRect.bottom() - 1.0)) + def drawLabel(self, painter, canvasRect, pos): + """ + Align and draw the text label of the marker + + :param QPainter painter: Painter + :param QRectF canvasRect: Contents rectangle of the canvas in painter coordinates + :param QPointF pos: Position of the marker, translated into widget coordinates + + .. seealso:: + + :py:meth:`drawLabel()`, + :py:meth:`qwt.symbol.QwtSymbol.drawSymbol()` + """ if self.__data.label.isEmpty(): return - align = Qt.Alignment(self.__data.labelAlignment) + align = self.__data.labelAlignment alignPos = QPointF(pos) symbolOff = QSizeF(0, 0) if self.__data.style == QwtPlotMarker.VLine: + # In VLine-style the y-position is pointless and + # the alignment flags are relative to the canvas if bool(self.__data.labelAlignment & Qt.AlignTop): alignPos.setY(canvasRect.top()) align &= ~Qt.AlignTop align |= Qt.AlignBottom elif bool(self.__data.labelAlignment & Qt.AlignBottom): - alignPos.setY(canvasRect.bottom()-1) + # In HLine-style the x-position is pointless and + # the alignment flags are relative to the canvas + alignPos.setY(canvasRect.bottom() - 1) align &= ~Qt.AlignBottom align |= Qt.AlignTop else: @@ -129,153 +309,324 @@ def drawLabel(self, painter, canvasRect, pos): align &= ~Qt.AlignLeft align |= Qt.AlignRight elif bool(self.__data.labelAlignment & Qt.AlignRight): - alignPos.setX(canvasRect.right()-1) + alignPos.setX(canvasRect.right() - 1) align &= ~Qt.AlignRight align |= Qt.AlignLeft else: alignPos.setX(canvasRect.center().x()) else: - if self.__data.symbol and\ - self.__data.symbol.style() != QwtSymbol.NoSymbol: - symbolOff = self.__data.symbol.size()+QSizeF(1, 1) + if self.__data.symbol and self.__data.symbol.style() != QwtSymbol.NoSymbol: + symbolOff = QSizeF(self.__data.symbol.size()) + QSizeF(1, 1) symbolOff /= 2 - pw2 = self.__data.pen.widthF()/2. - if pw2 == 0.: - pw2 = .5 + pw2 = self.__data.pen.widthF() / 2.0 + if pw2 == 0.0: + pw2 = 0.5 spacing = self.__data.spacing xOff = max([pw2, symbolOff.width()]) yOff = max([pw2, symbolOff.height()]) textSize = self.__data.label.textSize(painter.font()) if align & Qt.AlignLeft: - alignPos.setX(alignPos.x()-(xOff+spacing)) + alignPos.setX(alignPos.x() - (xOff + spacing)) if self.__data.labelOrientation == Qt.Vertical: - alignPos.setX(alignPos.x()-textSize.height()) + alignPos.setX(alignPos.x() - textSize.height()) else: - alignPos.setX(alignPos.x()-textSize.width()) + alignPos.setX(alignPos.x() - textSize.width()) elif align & Qt.AlignRight: - alignPos.setX(alignPos.x()+xOff+spacing) + alignPos.setX(alignPos.x() + xOff + spacing) else: if self.__data.labelOrientation == Qt.Vertical: - alignPos.setX(alignPos.x()-textSize.height()/2) + alignPos.setX(alignPos.x() - textSize.height() / 2) else: - alignPos.setX(alignPos.x()-textSize.width()/2) + alignPos.setX(alignPos.x() - textSize.width() / 2) if align & Qt.AlignTop: - alignPos.setY(alignPos.y()-(yOff+spacing)) + alignPos.setY(alignPos.y() - (yOff + spacing)) if self.__data.labelOrientation != Qt.Vertical: - alignPos.setY(alignPos.y()-textSize.height()) + alignPos.setY(alignPos.y() - textSize.height()) elif align & Qt.AlignBottom: - alignPos.setY(alignPos.y()+yOff+spacing) + alignPos.setY(alignPos.y() + yOff + spacing) if self.__data.labelOrientation == Qt.Vertical: - alignPos.setY(alignPos.y()+textSize.width()) + alignPos.setY(alignPos.y() + textSize.width()) else: if self.__data.labelOrientation == Qt.Vertical: - alignPos.setY(alignPos.y()+textSize.width()/2) + alignPos.setY(alignPos.y() + textSize.width() / 2) else: - alignPos.setY(alignPos.y()-textSize.height()/2) + alignPos.setY(alignPos.y() - textSize.height() / 2) painter.translate(alignPos.x(), alignPos.y()) if self.__data.labelOrientation == Qt.Vertical: - painter.rotate(-90.) + painter.rotate(-90.0) textRect = QRectF(0, 0, textSize.width(), textSize.height()) self.__data.label.draw(painter, textRect) - + def setLineStyle(self, style): + """ + Set the line style + + :param int style: Line style + + Line styles: + + * `QwtPlotMarker.NoLine`: No line + * `QwtPlotMarker.HLine`: A horizontal line + * `QwtPlotMarker.VLine`: A vertical line + * `QwtPlotMarker.Cross`: A crosshair + + .. seealso:: + + :py:meth:`lineStyle()` + """ if style != self.__data.style: self.__data.style = style self.legendChanged() self.itemChanged() - + def lineStyle(self): + """ + :return: the line style + + .. seealso:: + + :py:meth:`setLineStyle()` + """ return self.__data.style - + def setSymbol(self, symbol): + """ + Assign a symbol + + :param qwt.symbol.QwtSymbol symbol: New symbol + + .. seealso:: + + :py:meth:`symbol()` + """ if symbol != self.__data.symbol: self.__data.symbol = symbol if symbol is not None: self.setLegendIconSize(symbol.boundingRect().size()) self.legendChanged() self.itemChanged() - + def symbol(self): + """ + :return: the symbol + + .. seealso:: + + :py:meth:`setSymbol()` + """ return self.__data.symbol - + def setLabel(self, label): + """ + Set the label + + :param label: Label text + :type label: qwt.text.QwtText or str + + .. seealso:: + + :py:meth:`label()` + """ + if not isinstance(label, QwtText): + label = QwtText(label) if label != self.__data.label: self.__data.label = label self.itemChanged() - + def label(self): + """ + :return: the label + + .. seealso:: + + :py:meth:`setLabel()` + """ return self.__data.label - + def setLabelAlignment(self, align): + """ + Set the alignment of the label + + In case of `QwtPlotMarker.HLine` the alignment is relative to the + y position of the marker, but the horizontal flags correspond to the + canvas rectangle. In case of `QwtPlotMarker.VLine` the alignment is + relative to the x position of the marker, but the vertical flags + correspond to the canvas rectangle. + + In all other styles the alignment is relative to the marker's position. + + :param Qt.Alignment align: Alignment + + .. seealso:: + + :py:meth:`labelAlignment()`, :py:meth:`labelOrientation()` + """ if align != self.__data.labelAlignment: self.__data.labelAlignment = align self.itemChanged() - + def labelAlignment(self): + """ + :return: the label alignment + + .. seealso:: + + :py:meth:`setLabelAlignment()`, :py:meth:`setLabelOrientation()` + """ return self.__data.labelAlignment - + def setLabelOrientation(self, orientation): + """ + Set the orientation of the label + + When orientation is `Qt.Vertical` the label is rotated by 90.0 degrees + (from bottom to top). + + :param Qt.Orientation orientation: Orientation of the label + + .. seealso:: + + :py:meth:`labelOrientation()`, :py:meth:`setLabelAlignment()` + """ if orientation != self.__data.labelOrientation: self.__data.labelOrientation = orientation self.itemChanged() - + def labelOrientation(self): + """ + :return: the label orientation + + .. seealso:: + + :py:meth:`setLabelOrientation()`, :py:meth:`labelAlignment()` + """ return self.__data.labelOrientation - + def setSpacing(self, spacing): + """ + Set the spacing + + When the label is not centered on the marker position, the spacing + is the distance between the position and the label. + + :param int spacing: Spacing + + .. seealso:: + + :py:meth:`spacing()`, :py:meth:`setLabelAlignment()` + """ if spacing < 0: spacing = 0 if spacing != self.__data.spacing: self.__data.spacing = spacing self.itemChanged() - + def spacing(self): + """ + :return: the spacing + + .. seealso:: + + :py:meth:`setSpacing()` + """ return self.__data.spacing - def setLinePen(self, *args): + """ + Build and/or assigna a line pen, depending on the arguments. + + .. py:method:: setLinePen(color, width, style) + :noindex: + + Build and assign a line pen + + In Qt5 the default pen width is 1.0 ( 0.0 in Qt4 ) what makes it + non cosmetic (see `QPen.isCosmetic()`). This method signature has + been introduced to hide this incompatibility. + + :param QColor color: Pen color + :param float width: Pen width + :param Qt.PenStyle style: Pen style + + .. py:method:: setLinePen(pen) + :noindex: + + Specify a pen for the line. + + :param QPen pen: New pen + + .. seealso:: + + :py:meth:`pen()`, :py:meth:`brush()` + """ if len(args) == 1 and isinstance(args[0], QPen): - pen, = args + (pen,) = args elif len(args) in (1, 2, 3): color = args[0] - width = 0. + width = 0.0 style = Qt.SolidLine if len(args) > 1: width = args[1] if len(args) > 2: style = args[2] - self.setLinePen(QPen(color, width, style)) + pen = QPen(color, width, style) + self.setLinePen(pen) else: - raise TypeError("%s().setLinePen() takes 1, 2 or 3 argument(s) "\ - "(%s given)" % (self.__class__.__name__, len(args))) + raise TypeError( + "%s().setLinePen() takes 1, 2 or 3 argument(s) " + "(%s given)" % (self.__class__.__name__, len(args)) + ) if pen != self.__data.pen: self.__data.pen = pen self.legendChanged() self.itemChanged() - + def linePen(self): + """ + :return: the line pen + + .. seealso:: + + :py:meth:`setLinePen()` + """ return self.__data.pen def boundingRect(self): - return QRectF(self.__data.xValue, self.__data.yValue, 0., 0.) - + if self.__data.style == QwtPlotMarker.HLine: + return QRectF(self.__data.xValue, self.__data.yValue, -1.0, 0.0) + elif self.__data.style == QwtPlotMarker.VLine: + return QRectF(self.__data.xValue, self.__data.yValue, 0.0, -1.0) + else: + return QRectF(self.__data.xValue, self.__data.yValue, 0.0, 0.0) + def legendIcon(self, index, size): + """ + :param int index: Index of the legend entry (ignored as there is only one) + :param QSizeF size: Icon size + :return: Icon representing the marker on the legend + + .. seealso:: + + :py:meth:`qwt.plot.QwtPlotItem.setLegendIconSize()`, + :py:meth:`qwt.plot.QwtPlotItem.legendData()` + """ if size.isEmpty(): return QwtGraphic() icon = QwtGraphic() icon.setDefaultSize(size) icon.setRenderHint(QwtGraphic.RenderPensUnscaled, True) painter = QPainter(icon) - painter.setRenderHint(QPainter.Antialiasing, - self.testRenderHint(QwtPlotItem.RenderAntialiased)) + painter.setRenderHint( + QPainter.Antialiasing, self.testRenderHint(QwtPlotItem.RenderAntialiased) + ) if self.__data.style != QwtPlotMarker.NoLine: painter.setPen(self.__data.pen) if self.__data.style in (QwtPlotMarker.HLine, QwtPlotMarker.Cross): - y = .5*size.height() - QwtPainter.drawLine(painter, 0., y, size.width(), y) + y = 0.5 * size.height() + painter.drawLine(QLineF(0.0, y, size.width(), y)) if self.__data.style in (QwtPlotMarker.VLine, QwtPlotMarker.Cross): - x = .5*size.width() - QwtPainter.drawLine(painter, x, 0., x, size.height()) + x = 0.5 * size.width() + painter.drawLine(QLineF(x, 0.0, x, size.height())) if self.__data.symbol: r = QRect(0, 0, size.width(), size.height()) self.__data.symbol.drawSymbol(painter, r) diff --git a/qwt/plot_renderer.py b/qwt/plot_renderer.py index 0591c11..d8b2640 100644 --- a/qwt/plot_renderer.py +++ b/qwt/plot_renderer.py @@ -5,7 +5,34 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from __future__ import division +""" +QwtPlotRenderer +--------------- + +.. autoclass:: QwtPlotRenderer + :members: +""" + +import math +import os.path as osp + +from qtpy.compat import getsavefilename +from qtpy.QtCore import QObject, QRect, QRectF, QSizeF, Qt +from qtpy.QtGui import ( + QColor, + QImage, + QImageWriter, + QPageSize, + QPaintDevice, + QPainter, + QPainterPath, + QPalette, + QPen, + QTransform, +) +from qtpy.QtPrintSupport import QPrinter +from qtpy.QtSvg import QSvgGenerator +from qtpy.QtWidgets import QFileDialog from qwt.painter import QwtPainter from qwt.plot import QwtPlot @@ -13,33 +40,55 @@ from qwt.scale_draw import QwtScaleDraw from qwt.scale_map import QwtScaleMap -from qwt.qt.QtGui import (QPrinter, QPainter, QImageWriter, QImage, QColor, - QPaintDevice, QTransform, QPalette, QFileDialog, - QPainterPath, QPen) -from qwt.qt.QtCore import Qt, QRect, QRectF, QObject, QSizeF -from qwt.qt.QtSvg import QSvgGenerator -from qwt.qt.compat import getsavefilename - -import numpy as np -import os.path as osp - def qwtCanvasClip(canvas, canvasRect): - x1 = np.ceil(canvasRect.left()) - x2 = np.floor(canvasRect.right()) - y1 = np.ceil(canvasRect.top()) - y2 = np.floor(canvasRect.bottom()) - r = QRect(x1, y1, x2-x1-1, y2-y1-1) + """ + The clip region is calculated in integers + To avoid too much rounding errors better + calculate it in target device resolution + """ + x1 = math.ceil(canvasRect.left()) + x2 = math.floor(canvasRect.right()) + y1 = math.ceil(canvasRect.top()) + y2 = math.floor(canvasRect.bottom()) + r = QRect(x1, y1, x2 - x1 - 1, y2 - y1 - 1) return canvas.borderPath(r) -class QwtPlotRenderer_PrivateData(object): +class QwtPlotRenderer_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.discardFlags = QwtPlotRenderer.DiscardNone self.layoutFlags = QwtPlotRenderer.DefaultLayout + class QwtPlotRenderer(QObject): - + """ + Renderer for exporting a plot to a document, a printer + or anything else, that is supported by QPainter/QPaintDevice + + Discard flags: + + * `QwtPlotRenderer.DiscardNone`: Render all components of the plot + * `QwtPlotRenderer.DiscardBackground`: Don't render the background of the plot + * `QwtPlotRenderer.DiscardTitle`: Don't render the title of the plot + * `QwtPlotRenderer.DiscardLegend`: Don't render the legend of the plot + * `QwtPlotRenderer.DiscardCanvasBackground`: Don't render the background of the canvas + * `QwtPlotRenderer.DiscardFooter`: Don't render the footer of the plot + * `QwtPlotRenderer.DiscardCanvasFrame`: Don't render the frame of the canvas + + .. note:: + + The `QwtPlotRenderer.DiscardCanvasFrame` flag has no effect when using + style sheets, where the frame is part of the background + + Layout flags: + + * `QwtPlotRenderer.DefaultLayout`: Use the default layout as on screen + * `QwtPlotRenderer.FrameWithScales`: Instead of the scales a box is painted around the plot canvas, where the scale ticks are aligned to. + """ + # enum DiscardFlag DiscardNone = 0x00 DiscardBackground = 0x01 @@ -48,47 +97,134 @@ class QwtPlotRenderer(QObject): DiscardCanvasBackground = 0x08 DiscardFooter = 0x10 DiscardCanvasFrame = 0x20 - + # enum LayoutFlag DefaultLayout = 0x00 FrameWithScales = 0x01 - + def __init__(self, parent=None): QObject.__init__(self, parent) self.__data = QwtPlotRenderer_PrivateData() - - def setDiscardFlag(self, flag, on): + + def setDiscardFlag(self, flag, on=True): + """ + Change a flag, indicating what to discard from rendering + + :param int flag: Flag to change + :param bool on: On/Off + + .. seealso:: + + :py:meth:`testDiscardFlag()`, :py:meth:`setDiscardFlags()`, + :py:meth:`discardFlags()` + """ if on: self.__data.discardFlags |= flag else: self.__data.discardFlags &= ~flag - + def testDiscardFlag(self, flag): + """ + :param int flag: Flag to be tested + :return: True, if flag is enabled. + + .. seealso:: + + :py:meth:`setDiscardFlag()`, :py:meth:`setDiscardFlags()`, + :py:meth:`discardFlags()` + """ return self.__data.discardFlags & flag - + def setDiscardFlags(self, flags): + """ + Set the flags, indicating what to discard from rendering + + :param int flags: Flags + + .. seealso:: + + :py:meth:`testDiscardFlag()`, :py:meth:`setDiscardFlag()`, + :py:meth:`discardFlags()` + """ self.__data.discardFlags = flags - + def discardFlags(self): + """ + :return: Flags, indicating what to discard from rendering + + .. seealso:: + + :py:meth:`setDiscardFlag()`, :py:meth:`setDiscardFlags()`, + :py:meth:`testDiscardFlag()` + """ return self.__data.discardFlags - - def setLayoutFlag(self, flag, on): + + def setLayoutFlag(self, flag, on=True): + """ + Change a layout flag + + :param int flag: Flag to change + + .. seealso:: + + :py:meth:`testLayoutFlag()`, :py:meth:`setLayoutFlags()`, + :py:meth:`layoutFlags()` + """ if on: self.__data.layoutFlags |= flag else: self.__data.layoutFlags &= ~flag - + def testLayoutFlag(self, flag): + """ + :param int flag: Flag to be tested + :return: True, if flag is enabled. + + .. seealso:: + + :py:meth:`setLayoutFlag()`, :py:meth:`setLayoutFlags()`, + :py:meth:`layoutFlags()` + """ return self.__data.layoutFlags & flag def setLayoutFlags(self, flags): + """ + Set the layout flags + + :param int flags: Flags + + .. seealso:: + + :py:meth:`setLayoutFlag()`, :py:meth:`testLayoutFlag()`, + :py:meth:`layoutFlags()` + """ self.__data.layoutFlags = flags - + def layoutFlags(self): + """ + :return: Layout flags + + .. seealso:: + + :py:meth:`setLayoutFlags()`, :py:meth:`setLayoutFlag()`, + :py:meth:`testLayoutFlag()` + """ return self.__data.layoutFlags - - def renderDocument(self, plot, filename, sizeMM=(300, 200), resolution=85, - format_=None): + + def renderDocument( + self, plot, filename, sizeMM=(300, 200), resolution=85, format_=None + ): + """ + Render a plot to a file + + The format of the document will be auto-detected from the + suffix of the file name. + + :param qwt.plot.QwtPlot plot: Plot widget + :param str fileName: Path of the file, where the document will be stored + :param QSizeF sizeMM: Size for the document in millimeters + :param int resolution: Resolution in dots per Inch (dpi) + """ if isinstance(sizeMM, tuple): sizeMM = QSizeF(*sizeMM) if format_ is None: @@ -101,19 +237,27 @@ def renderDocument(self, plot, filename, sizeMM=(300, 200), resolution=85, title = plot.title().text() if not title: title = "Plot Document" - mmToInch = 1./25.4 + mmToInch = 1.0 / 25.4 size = sizeMM * mmToInch * resolution documentRect = QRectF(0.0, 0.0, size.width(), size.height()) fmt = format_.lower() if fmt in ("pdf", "ps"): printer = QPrinter() if fmt == "pdf": - printer.setOutputFormat(QPrinter.PdfFormat) + try: + printer.setOutputFormat(QPrinter.PdfFormat) + except AttributeError: + # PyQt6 on Linux + printer.setPrinterName("") else: printer.setOutputFormat(QPrinter.PostScriptFormat) - printer.setColorMode(QPrinter.Color) + try: + printer.setColorMode(QPrinter.Color) + except AttributeError: + # PyQt6 on Linux + pass printer.setFullPage(True) - printer.setPaperSize(sizeMM, QPrinter.Millimeter) + printer.setPageSize(QPageSize(sizeMM, QPageSize.Millimeter)) printer.setDocName(title) printer.setOutputFileName(filename) printer.setResolution(resolution) @@ -131,7 +275,7 @@ def renderDocument(self, plot, filename, sizeMM=(300, 200), resolution=85, painter.end() elif fmt in QImageWriter.supportedImageFormats(): imageRect = documentRect.toRect() - dotsPerMeter = int(round(resolution*mmToInch*1000.)) + dotsPerMeter = int(round(resolution * mmToInch * 1000.0)) image = QImage(imageRect.size(), QImage.Format_ARGB32) image.setDotsPerMeterX(dotsPerMeter) image.setDotsPerMeterY(dotsPerMeter) @@ -144,6 +288,27 @@ def renderDocument(self, plot, filename, sizeMM=(300, 200), resolution=85, raise TypeError("Unsupported file format '%s'" % fmt) def renderTo(self, plot, dest): + """ + Render a plot to a file + + Supported formats are: + + - pdf: Portable Document Format PDF + - ps: Postcript + - svg: Scalable Vector Graphics SVG + - all image formats supported by Qt, see QImageWriter.supportedImageFormats() + + Scalable vector graphic formats like PDF or SVG are superior to + raster graphics formats. + + :param qwt.plot.QwtPlot plot: Plot widget + :param dest: QPaintDevice, QPrinter or QSvgGenerator instance + + .. seealso:: + + :py:meth:`render()`, + :py:meth:`qwt.painter.QwtPainter.setRoundingAlignment()` + """ if isinstance(dest, QPaintDevice): w = dest.width() h = dest.height() @@ -152,48 +317,76 @@ def renderTo(self, plot, dest): w = dest.width() h = dest.height() rect = QRectF(0, 0, w, h) - aspect = rect.width()/rect.height() - if aspect < 1.: - rect.setHeight(aspect*rect.width()) + aspect = rect.width() / rect.height() + if aspect < 1.0: + rect.setHeight(aspect * rect.width()) elif isinstance(dest, QSvgGenerator): rect = dest.viewBoxF() if rect.isEmpty(): rect.setRect(0, 0, dest.width(), dest.height()) if rect.isEmpty(): rect.setRect(0, 0, 800, 600) + else: + raise TypeError("Unsupported destination type %s" % type(dest)) p = QPainter(dest) self.render(plot, p, rect) - + def render(self, plot, painter, plotRect): - if painter == 0 or not painter.isActive() or not plotRect.isValid()\ - or plot.size().isNull(): + """ + Paint the contents of a QwtPlot instance into a given rectangle. + + :param qwt.plot.QwtPlot plot: Plot to be rendered + :param QPainter painter: Painter + :param str format: Format for the document + :param QRectF plotRect: Bounding rectangle + + .. seealso:: + + :py:meth:`renderDocument()`, :py:meth:`renderTo()`, + :py:meth:`qwt.painter.QwtPainter.setRoundingAlignment()` + """ + if ( + painter == 0 + or not painter.isActive() + or not plotRect.isValid() + or plot.size().isNull() + ): return if not self.__data.discardFlags & self.DiscardBackground: QwtPainter.drawBackground(painter, plotRect, plot) + # The layout engine uses the same methods as they are used + # by the Qt layout system. Therefore we need to calculate the + # layout in screen coordinates and paint with a scaled painter. transform = QTransform() - transform.scale(float(painter.device().logicalDpiX())/plot.logicalDpiX(), - float(painter.device().logicalDpiY())/plot.logicalDpiY()) - + transform.scale( + float(painter.device().logicalDpiX()) / plot.logicalDpiX(), + float(painter.device().logicalDpiY()) / plot.logicalDpiY(), + ) + invtrans, _ok = transform.inverted() layoutRect = invtrans.mapRect(plotRect) if not (self.__data.discardFlags & self.DiscardBackground): - left, top, right, bottom = plot.getContentsMargins() - layoutRect.adjust(left, top, -right, -bottom) + mg = plot.contentsMargins() + layoutRect.adjust(mg.left(), mg.top(), -mg.right(), -mg.bottom()) layout = plot.plotLayout() - baseLineDists = [None]*QwtPlot.axisCnt - canvasMargins = [None]*QwtPlot.axisCnt + baseLineDists = canvasMargins = [None] * len(QwtPlot.AXES) - for axisId in range(QwtPlot.axisCnt): + for axisId in QwtPlot.AXES: canvasMargins[axisId] = layout.canvasMargin(axisId) if self.__data.layoutFlags & self.FrameWithScales: scaleWidget = plot.axisWidget(axisId) if scaleWidget: - baseLineDists[axisId] = scaleWidget.margin() + mgn = scaleWidget.contentsMargins() + baseLineDists[axisId] = max( + [mgn.left(), mgn.top(), mgn.right(), mgn.bottom()] + ) scaleWidget.setMargin(0) if not plot.axisEnabled(axisId): - left, right, top, bottom = 0, 0, 0, 0 + # When we have a scale the frame is painted on + # the position of the backbone - otherwise we + # need to introduce a margin around the canvas if axisId == QwtPlot.yLeft: layoutRect.adjust(1, 0, 0, 0) elif axisId == QwtPlot.yRight: @@ -202,56 +395,71 @@ def render(self, plot, painter, plotRect): layoutRect.adjust(0, 1, 0, 0) elif axisId == QwtPlot.xBottom: layoutRect.adjust(0, 0, 0, -1) - layoutRect.adjust(left, top, right, bottom) - + + # Calculate the layout for the document. layoutOptions = QwtPlotLayout.IgnoreScrollbars - - if self.__data.layoutFlags & self.FrameWithScales or\ - self.__data.discardFlags & self.DiscardCanvasFrame: + + if ( + self.__data.layoutFlags & self.FrameWithScales + or self.__data.discardFlags & self.DiscardCanvasFrame + ): layoutOptions |= QwtPlotLayout.IgnoreFrames - + if self.__data.discardFlags & self.DiscardLegend: layoutOptions |= QwtPlotLayout.IgnoreLegend if self.__data.discardFlags & self.DiscardTitle: layoutOptions |= QwtPlotLayout.IgnoreTitle if self.__data.discardFlags & self.DiscardFooter: layoutOptions |= QwtPlotLayout.IgnoreFooter - + layout.activate(plot, layoutRect, layoutOptions) maps = self.buildCanvasMaps(plot, layout.canvasRect()) if self.updateCanvasMargins(plot, layout.canvasRect(), maps): + # recalculate maps and layout, when the margins + # have been changed layout.activate(plot, layoutRect, layoutOptions) maps = self.buildCanvasMaps(plot, layout.canvasRect()) - + painter.save() painter.setWorldTransform(transform, True) - + self.renderCanvas(plot, painter, layout.canvasRect(), maps) - - if (not self.__data.discardFlags & self.DiscardTitle) and\ - plot.titleLabel().text(): + + if ( + not self.__data.discardFlags & self.DiscardTitle + ) and plot.titleLabel().text(): self.renderTitle(plot, painter, layout.titleRect()) - - if (not self.__data.discardFlags & self.DiscardFooter) and\ - plot.titleLabel().text(): + + if ( + not self.__data.discardFlags & self.DiscardFooter + ) and plot.titleLabel().text(): self.renderFooter(plot, painter, layout.footerRect()) - - if (not self.__data.discardFlags & self.DiscardLegend) and\ - plot.titleLabel().text(): + + if ( + not self.__data.discardFlags & self.DiscardLegend + ) and plot.titleLabel().text(): self.renderLegend(plot, painter, layout.legendRect()) - - for axisId in range(QwtPlot.axisCnt): + + for axisId in QwtPlot.AXES: scaleWidget = plot.axisWidget(axisId) if scaleWidget: - baseDist = scaleWidget.margin() + mgn = scaleWidget.contentsMargins() + baseDist = max([mgn.left(), mgn.top(), mgn.right(), mgn.bottom()]) startDist, endDist = scaleWidget.getBorderDistHint() - self.renderScale(plot, painter, axisId, startDist, endDist, - baseDist, layout.scaleRect(axisId)) - + self.renderScale( + plot, + painter, + axisId, + startDist, + endDist, + baseDist, + layout.scaleRect(axisId), + ) + painter.restore() - - for axisId in range(QwtPlot.axisCnt): + + for axisId in QwtPlot.AXES: if self.__data.layoutFlags & self.FrameWithScales: scaleWidget = plot.axisWidget(axisId) if scaleWidget: @@ -259,26 +467,58 @@ def render(self, plot, painter, plotRect): layout.setCanvasMargin(canvasMargins[axisId]) layout.invalidate() - + def renderTitle(self, plot, painter, rect): + """ + Render the title into a given rectangle. + + :param qwt.plot.QwtPlot plot: Plot widget + :param QPainter painter: Painter + :param QRectF rect: Bounding rectangle + """ painter.setFont(plot.titleLabel().font()) color = plot.titleLabel().palette().color(QPalette.Active, QPalette.Text) painter.setPen(color) plot.titleLabel().text().draw(painter, rect) - + def renderFooter(self, plot, painter, rect): + """ + Render the footer into a given rectangle. + + :param qwt.plot.QwtPlot plot: Plot widget + :param QPainter painter: Painter + :param QRectF rect: Bounding rectangle + """ painter.setFont(plot.footerLabel().font()) color = plot.footerLabel().palette().color(QPalette.Active, QPalette.Text) painter.setPen(color) plot.footerLabel().text().draw(painter, rect) - + def renderLegend(self, plot, painter, rect): + """ + Render the legend into a given rectangle. + + :param qwt.plot.QwtPlot plot: Plot widget + :param QPainter painter: Painter + :param QRectF rect: Bounding rectangle + """ if plot.legend(): fillBackground = not self.__data.discardFlags & self.DiscardBackground plot.legend().renderLegend(painter, rect, fillBackground) - - def renderScale(self, plot, painter, axisId, startDist, endDist, - baseDist, rect): + + def renderScale(self, plot, painter, axisId, startDist, endDist, baseDist, rect): + """ + Paint a scale into a given rectangle. + Paint the scale into a given rectangle. + + :param qwt.plot.QwtPlot plot: Plot widget + :param QPainter painter: Painter + :param int axisId: Axis + :param int startDist: Start border distance + :param int endDist: End border distance + :param int baseDist: Base distance + :param QRectF rect: Bounding rectangle + """ if not plot.axisEnabled(axisId): return scaleWidget = plot.axisWidget(axisId) @@ -301,12 +541,12 @@ def renderScale(self, plot, painter, axisId, startDist, endDist, y = rect.bottom() - 1.0 - baseDist w = rect.width() - startDist - endDist align = QwtScaleDraw.TopScale - elif axisId == QwtPlot.xBottom: + else: # QwtPlot.xBottom x = rect.left() + startDist y = rect.top() + baseDist w = rect.width() - startDist - endDist align = QwtScaleDraw.BottomScale - + scaleWidget.drawTitle(painter, align, rect) painter.setFont(scaleWidget.font()) sd = scaleWidget.scaleDraw() @@ -320,18 +560,26 @@ def renderScale(self, plot, painter, axisId, startDist, endDist, sd.move(sdPos) sd.setLength(sdLength) painter.restore() - + def renderCanvas(self, plot, painter, canvasRect, maps): + """ + Render the canvas into a given rectangle. + + :param qwt.plot.QwtPlot plot: Plot widget + :param QPainter painter: Painter + :param QRectF rect: Bounding rectangle + :param qwt.scale_map.QwtScaleMap maps: mapping between plot and paint device coordinates + """ canvas = plot.canvas() - r = canvasRect.adjusted(0., 0., -1., 1.) + r = canvasRect.adjusted(0.0, 0.0, -1.0, 1.0) if self.__data.layoutFlags & self.FrameWithScales: painter.save() - r.adjust(-1., -1., 1., 1.) + r.adjust(-1.0, -1.0, 1.0, 1.0) painter.setPen(QPen(Qt.black)) if not (self.__data.discardFlags & self.DiscardCanvasBackground): bgBrush = canvas.palette().brush(plot.backgroundRole()) painter.setBrush(bgBrush) - QwtPainter.drawRect(painter, r) + painter.drawRect(r) painter.restore() painter.save() painter.setClipRect(canvasRect) @@ -357,8 +605,9 @@ def renderCanvas(self, plot, painter, canvasRect, maps): if not self.__data.discardFlags & self.DiscardCanvasFrame: frameWidth = canvas.frameWidth() clipPath = qwtCanvasClip(canvas, canvasRect) - innerRect = canvasRect.adjusted(frameWidth, frameWidth, - -frameWidth, -frameWidth) + innerRect = canvasRect.adjusted( + frameWidth, frameWidth, -frameWidth, -frameWidth + ) painter.save() if clipPath.isEmpty(): painter.setClipRect(innerRect) @@ -371,25 +620,42 @@ def renderCanvas(self, plot, painter, canvasRect, maps): if frameWidth > 0: painter.save() frameStyle = canvas.frameShadow() | canvas.frameShape() - frameWidth = canvas.frameWidth() - borderRadius = canvas.borderRadius() - if borderRadius > 0.: - QwtPainter.drawRoundedFrame(painter, canvasRect, r, r, - canvas.palette(), frameWidth, - frameStyle) + radius = canvas.borderRadius() + if radius > 0.0: + QwtPainter.drawRoundedFrame( + painter, + canvasRect, + radius, + radius, + canvas.palette(), + frameWidth, + frameStyle, + ) else: midLineWidth = canvas.midLineWidth() - QwtPainter.drawFrame(painter, canvasRect, canvas.palette(), - canvas.foregroundRole(), frameWidth, - midLineWidth, frameStyle) + QwtPainter.drawFrame( + painter, + canvasRect, + canvas.palette(), + canvas.foregroundRole(), + frameWidth, + midLineWidth, + frameStyle, + ) painter.restore() def buildCanvasMaps(self, plot, canvasRect): + """ + Calculated the scale maps for rendering the canvas + + :param qwt.plot.QwtPlot plot: Plot widget + :param QRectF canvasRect: Target rectangle + :return: Calculated scale maps + """ maps = [] - for axisId in range(QwtPlot.axisCnt): + for axisId in QwtPlot.AXES: map_ = QwtScaleMap() - map_.setTransformation( - plot.axisScaleEngine(axisId).transformation()) + map_.setTransformation(plot.axisScaleEngine(axisId).transformation()) sd = plot.axisScaleDiv(axisId) map_.setScaleInterval(sd.lowerBound(), sd.upperBound()) if plot.axisEnabled(axisId): @@ -414,40 +680,60 @@ def buildCanvasMaps(self, plot, canvasRect): map_.setPaintInterval(from_, to) maps.append(map_) return maps - + def updateCanvasMargins(self, plot, canvasRect, maps): margins = plot.getCanvasMarginsHint(maps, canvasRect) marginsChanged = False - for axisId in range(QwtPlot.axisCnt): - if margins[axisId] >= 0.: - m = np.ceil(margins[axisId]) + for axisId in QwtPlot.AXES: + if margins[axisId] >= 0.0: + m = math.ceil(margins[axisId]) plot.plotLayout().setCanvasMargin(m, axisId) marginsChanged = True return marginsChanged - + def exportTo(self, plot, documentname, sizeMM=None, resolution=85): + """ + Execute a file dialog and render the plot to the selected file + + :param qwt.plot.QwtPlot plot: Plot widget + :param str documentName: Default document name + :param QSizeF sizeMM: Size for the document in millimeters + :param int resolution: Resolution in dots per Inch (dpi) + :return: True, when exporting was successful + + .. seealso:: + + :py:meth:`renderDocument()` + """ if plot is None: return if sizeMM is None: sizeMM = QSizeF(300, 200) filename = documentname imageFormats = QImageWriter.supportedImageFormats() - filter_ = ["PDF documents (*.pdf)", - "SVG documents (*.svg)", - "Postscript documents (*.ps)"] + filter_ = [ + "PDF documents (*.pdf)", + "SVG documents (*.svg)", + "Postscript documents (*.ps)", + ] if imageFormats: imageFilter = "Images" imageFilter += " (" for idx, fmt in enumerate(imageFormats): if idx > 0: imageFilter += " " - imageFilter += "*."+str(fmt) + imageFilter += "*." + str(fmt) imageFilter += ")" filter_ += [imageFilter] - filename, _s = getsavefilename(plot, "Export File Name", filename, - ";;".join(filter_), - options=QFileDialog.DontConfirmOverwrite) + filename, _s = getsavefilename( + plot, + "Export File Name", + filename, + ";;".join(filter_), + options=QFileDialog.DontConfirmOverwrite, + ) if not filename: return False self.renderDocument(plot, filename, sizeMM, resolution) return True + return True diff --git a/qwt/plot_series.py b/qwt/plot_series.py new file mode 100644 index 0000000..e0f21f8 --- /dev/null +++ b/qwt/plot_series.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# Copyright (c) 2002 Uwe Rathmann, for the original C++ code +# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization +# (see LICENSE file for more details) + +""" +Plotting series item +-------------------- + +QwtPlotSeriesItem +~~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtPlotSeriesItem + :members: + +QwtSeriesData +~~~~~~~~~~~~~ + +.. autoclass:: QwtSeriesData + :members: + +QwtPointArrayData +~~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtPointArrayData + :members: + +QwtSeriesStore +~~~~~~~~~~~~~~ + +.. autoclass:: QwtSeriesStore + :members: +""" + +import numpy as np +from qtpy.QtCore import QPointF, QRectF, Qt + +from qwt.plot import QwtPlotItem, QwtPlotItem_PrivateData +from qwt.text import QwtText + + +class QwtPlotSeriesItem_PrivateData(QwtPlotItem_PrivateData): + def __init__(self): + QwtPlotItem_PrivateData.__init__(self) + self.orientation = Qt.Horizontal + + +class QwtPlotSeriesItem(QwtPlotItem): + """ + Base class for plot items representing a series of samples + """ + + def __init__(self, title): + if not isinstance(title, QwtText): + title = QwtText(title) + QwtPlotItem.__init__(self, title) + self.__data = QwtPlotSeriesItem_PrivateData() + self.setItemInterest(QwtPlotItem.ScaleInterest, True) + + def setOrientation(self, orientation): + """ + Set the orientation of the item. Default is `Qt.Horizontal`. + + The `orientation()` might be used in specific way by a plot item. + F.e. a QwtPlotCurve uses it to identify how to display the curve + int `QwtPlotCurve.Steps` or `QwtPlotCurve.Sticks` style. + + .. seealso:: + + :py:meth`orientation()` + """ + if self.__data.orientation != orientation: + self.__data.orientation = orientation + self.legendChanged() + self.itemChanged() + + def orientation(self): + """ + :return: Orientation of the plot item + + .. seealso:: + + :py:meth`setOrientation()` + """ + return self.__data.orientation + + def draw(self, painter, xMap, yMap, canvasRect): + """ + Draw the complete series + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + """ + self.drawSeries(painter, xMap, yMap, canvasRect, 0, -1) + + def drawSeries(self, painter, xMap, yMap, canvasRect, from_, to): + """ + Draw a subset of the samples + + :param QPainter painter: Painter + :param qwt.scale_map.QwtScaleMap xMap: Maps x-values into pixel coordinates. + :param qwt.scale_map.QwtScaleMap yMap: Maps y-values into pixel coordinates. + :param QRectF canvasRect: Contents rectangle of the canvas + :param int from_: Index of the first point to be painted + :param int to: Index of the last point to be painted. If to < 0 the curve will be painted to its last point. + + .. seealso:: + + This method is implemented in `qwt.plot_curve.QwtPlotCurve` + """ + raise NotImplementedError + + def boundingRect(self): + return self.dataRect() # dataRect method is implemented in QwtSeriesStore + + def updateScaleDiv(self, xScaleDiv, yScaleDiv): + rect = QRectF( + xScaleDiv.lowerBound(), + yScaleDiv.lowerBound(), + xScaleDiv.range(), + yScaleDiv.range(), + ) + self.setRectOfInterest( + rect + ) # setRectOfInterest method is implemented in QwtSeriesData + + def dataChanged(self): + self.itemChanged() + + +class QwtSeriesData(object): + """ + Abstract interface for iterating over samples + + `PythonQwt` offers several implementations of the QwtSeriesData API, + but in situations, where data of an application specific format + needs to be displayed, without having to copy it, it is recommended + to implement an individual data access. + + A subclass of `QwtSeriesData` must implement: + + - size(): + + Should return number of data points. + + - sample() + + Should return values x and y values of the sample at specific position + as QPointF object. + + - boundingRect() + + Should return the bounding rectangle of the data series. + It is used for autoscaling and might help certain algorithms for + displaying the data. + The member `_boundingRect` is intended for caching the calculated + rectangle. + """ + + def __init__(self): + self._boundingRect = QRectF(0.0, 0.0, -1.0, -1.0) + + def setRectOfInterest(self, rect): + """ + Set a the "rect of interest" + + QwtPlotSeriesItem defines the current area of the plot canvas + as "rectangle of interest" ( QwtPlotSeriesItem::updateScaleDiv() ). + It can be used to implement different levels of details. + + The default implementation does nothing. + + :param QRectF rect: Rectangle of interest + """ + pass + + def size(self): + """ + :return: Number of samples + """ + pass + + def sample(self, i): + """ + Return a sample + + :param int i: Index + :return: Sample at position i + """ + pass + + def boundingRect(self): + """ + Calculate the bounding rect of all samples + + The bounding rect is necessary for autoscaling and can be used + for a couple of painting optimizations. + + :return: Bounding rectangle + """ + pass + + +class QwtPointArrayData(QwtSeriesData): + """ + Interface for iterating over two array objects + + .. py:class:: QwtCQwtPointArrayDataolorMap(x, y, [size=None]) + + :param x: Array of x values + :type x: list or tuple or numpy.array + :param y: Array of y values + :type y: list or tuple or numpy.array + :param int size: Size of the x and y arrays + :param bool finite: if True, keep only finite array elements (remove all infinity and not a number values), otherwise do not filter array elements + """ + + def __init__(self, x=None, y=None, size=None, finite=None): + QwtSeriesData.__init__(self) + if x is None and y is not None: + x = np.arange(len(y)) + elif y is None and x is not None: + y = x + x = np.arange(len(y)) + elif x is None and y is None: + x = np.array([]) + y = np.array([]) + if isinstance(x, (tuple, list)): + x = np.array(x) + if isinstance(y, (tuple, list)): + y = np.array(y) + if size is not None: + x = np.resize(x, (size,)) + y = np.resize(y, (size,)) + if len(x) != len(y): + minlen = min(len(x), len(y)) + x = np.resize(x, (minlen,)) + y = np.resize(y, (minlen,)) + if finite if finite is not None else True: + indexes = np.logical_and(np.isfinite(x), np.isfinite(y)) + self.__x = x[indexes] + self.__y = y[indexes] + else: + self.__x = x + self.__y = y + + def boundingRect(self): + """ + Calculate the bounding rectangle + + The bounding rectangle is calculated once by iterating over all + points and is stored for all following requests. + + :return: Bounding rectangle + """ + xmin = self.__x.min() + xmax = self.__x.max() + ymin = self.__y.min() + ymax = self.__y.max() + return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) + + def size(self): + """ + :return: Size of the data set + """ + return min([self.__x.size, self.__y.size]) + + def sample(self, index): + """ + :param int index: Index + :return: Sample at position `index` + """ + return QPointF(self.__x[index], self.__y[index]) + + def xData(self): + """ + :return: Array of the x-values + """ + return self.__x + + def yData(self): + """ + :return: Array of the y-values + """ + return self.__y + + +class QwtSeriesStore(object): + """ + Class storing a `QwtSeriesData` object + + `QwtSeriesStore` and `QwtPlotSeriesItem` are intended as base classes for + all plot items iterating over a series of samples. + """ + + def __init__(self): + self.__series = None + + def setData(self, series): + """ + Assign a series of samples + + :param qwt.plot_series.QwtSeriesData series: Data + + .. warning:: + + The item takes ownership of the data object, deleting it + when its not used anymore. + """ + if self.__series != series: + self.__series = series + self.dataChanged() + + def dataChanged(self): + raise NotImplementedError + + def data(self): + """ + :return: the series data + """ + return self.__series + + def sample(self, index): + """ + :param int index: Index + :return: Sample at position index + """ + if self.__series: + return self.__series.sample(index) + else: + return + + def dataSize(self): + """ + :return: Number of samples of the series + + .. seealso:: + + :py:meth:`setData()`, + :py:meth:`qwt.plot_series.QwtSeriesData.size()` + """ + if self.__series is None: + return 0 + return self.__series.size() + + def dataRect(self): + """ + :return: Bounding rectangle of the series or an invalid rectangle, when no series is stored + + .. seealso:: + + :py:meth:`qwt.plot_series.QwtSeriesData.boundingRect()` + """ + if self.__series is None or self.dataSize() == 0: + return QRectF(1.0, 1.0, -2.0, -2.0) + return self.__series.boundingRect() + + def setRectOfInterest(self, rect): + """ + Set a the "rect of interest" for the series + + :param QRectF rect: Rectangle of interest + + .. seealso:: + + :py:meth:`qwt.plot_series.QwtSeriesData.setRectOfInterest()` + """ + if self.__series: + self.__series.setRectOfInterest(rect) + + def swapData(self, series): + """ + Replace a series without deleting the previous one + + :param qwt.plot_series.QwtSeriesData series: New series + :return: Previously assigned series + """ + swappedSeries = self.__series + self.__series = series + return swappedSeries diff --git a/qwt/plot_seriesitem.py b/qwt/plot_seriesitem.py deleted file mode 100644 index b52ee42..0000000 --- a/qwt/plot_seriesitem.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.plot import QwtPlotItem, QwtPlotItem_PrivateData -from qwt.text import QwtText -from qwt.series_store import QwtAbstractSeriesStore - -from qwt.qt.QtCore import Qt, QRectF - - -class QwtPlotSeriesItem_PrivateData(QwtPlotItem_PrivateData): - def __init__(self): - QwtPlotItem_PrivateData.__init__(self) - self.orientation = Qt.Vertical - - -class QwtPlotSeriesItem(QwtPlotItem, QwtAbstractSeriesStore): - def __init__(self, title): - QwtAbstractSeriesStore.__init__(self) - if not isinstance(title, QwtText): - title = QwtText(title) - QwtPlotItem.__init__(self, title) - self.__data = QwtPlotSeriesItem_PrivateData() - - def setOrientation(self, orientation): - if self.__data.orientation != orientation: - self.__data.orientation = orientation - self.legendChanged() - self.itemChanged() - - def orientation(self): - return self.__data.orientation - - def draw(self, painter, xMap, yMap, canvasRect): - self.drawSeries(painter, xMap, yMap, canvasRect, 0, -1) - - def boundingRect(self): - return self.dataRect() - - def updateScaleDiv(self, xScaleDiv, yScaleDiv): - rect = QRectF(xScaleDiv.lowerBound(), yScaleDiv.lowerBound(), - xScaleDiv.range(), yScaleDiv.range()) - self.setRectOfInterest(rect) - - def dataChanged(self): - self.itemChanged() diff --git a/qwt/point_data.py b/qwt/point_data.py deleted file mode 100644 index 2048ffe..0000000 --- a/qwt/point_data.py +++ /dev/null @@ -1,106 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.series_data import QwtSeriesData, qwtBoundingRect -from qwt.interval import QwtInterval - -from qwt.qt.QtCore import QPointF, QRectF -import numpy as np - - -class QwtPointArrayData(QwtSeriesData): - def __init__(self, x, y, size=None): - QwtSeriesData.__init__(self) - if isinstance(x, (tuple, list)): - x = np.array(x) - if isinstance(y, (tuple, list)): - y = np.array(y) - self.__x = x - self.__y = y - - def boundingRect(self): - xmin = self.__x.min() - xmax = self.__x.max() - ymin = self.__y.min() - ymax = self.__y.max() - return QRectF(xmin, ymin, xmax-xmin, ymax-ymin) - - def size(self): - return min([self.__x.size, self.__y.size]) - - def sample(self, index): - return QPointF(self.__x[index], self.__y[index]) - - def xData(self): - return self.__x - - def yData(self): - return self.__y - - -class QwtCPointerData(QwtPointArrayData): - def __init__(self, x, y, size): - QwtSeriesData.__init__(self) - self.__x = x - self.__y = y - self.__size = size - - def size(self): - return self.__size - - -class QwtSyntheticPointData(QwtSeriesData): - def __init__(self, size, interval): - QwtSeriesData.__init__(self) - self.__size = size - self.__interval = interval - self.__rectOfInterest = None - self.__intervalOfInterest = None - - def setSize(self, size): - self.__size = size - - def size(self): - return self.__size - - def setInterval(self, interval): - self.__interval = interval.normalized() - - def interval(self): - return self.__interval - - def setRectOfInterest(self, rect): - self.__rectOfInterest = rect - self.__intervalOfInterest = QwtInterval(rect.left(), rect.right() - ).normalized() - - def rectOfInterest(self): - return self.__rectOfInterest - - def boundingRect(self): - if self.__size == 0 or\ - not (self.__interval.isValid() or self.__intervalOfInterest.isValid()): - return QRectF(1.0, 1.0, -2.0, -2.0) - return qwtBoundingRect(self) - - def sample(self, index): - if index >= self.__size: - return QPointF(0, 0) - xValue = self.x(index) - yValue = self.y(xValue) - return QPointF(xValue, yValue) - - def x(self, index): - if self.__interval.isValid(): - interval = self.__interval - else: - interval = self.__intervalOfInterest - if not interval.isValid() or self.__size == 0 or index >= self.__size: - return 0. - dx = interval.width()/self.__size - return interval.minValue() + index*dx - \ No newline at end of file diff --git a/qwt/point_mapper.py b/qwt/point_mapper.py deleted file mode 100644 index c8745f4..0000000 --- a/qwt/point_mapper.py +++ /dev/null @@ -1,271 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -USE_THREADS = False # QtConcurrent is not supported by PyQt - -from qwt.qt.QtGui import QPolygon, QPolygonF, QImage, QPainter -from qwt.qt.QtCore import QThread, Qt, QPoint, QPointF, QRectF - -from qwt.pixel_matrix import QwtPixelMatrix - -import numpy as np - - -class QwtDotsCommand(object): - def __init__(self): - self.series = None - self.from_ = None - self.to = None - self.rgb = None - -def qwtRenderDots(xMap, yMap, command, pos, image): - rgb = command.rgb - bits = image.bits() - w = image.width() - h = image.height() - x0 = pos.x() - y0 = pos.y() - for i in range(command.from_, command.to+1): - sample = command.series.sample(i) - x = int(xMap.transform(sample.x())+0.5)-x0 - y = int(yMap.transform(sample.y())+0.5)-y0 - if x >= 0 and x < w and y >= 0 and y < h: - bits[y*w+x] = rgb - - -def qwtToPoints(boundingRect, xMap, yMap, series, from_, to, round_, - Polygon): - Point = QPointF if isinstance(Polygon, QPolygonF) else QPoint - points = [] - if boundingRect.isValid(): - for i in range(from_, to+1): - sample = series.sample(i) - x = xMap.transform(sample.x()) - y = yMap.transform(sample.y()) - if boundingRect.contains(x, y): - points.append(Point(round_(x), round_(y))) - else: - for i in range(from_, to+1): - sample = series.sample(i) - x = xMap.transform(sample.x()) - y = yMap.transform(sample.y()) - points.append(Point(round_(x), round_(y))) - return Polygon(list(set(points))) - -def qwtToPointsI(boundingRect, xMap, yMap, series, from_, to): - return qwtToPoints(boundingRect, xMap, yMap, series, from_, to, round, - QPolygon) - -def qwtToPointsF(boundingRect, xMap, yMap, series, from_, to, round_): - return qwtToPoints(boundingRect, xMap, yMap, series, from_, to, round_, - QPolygonF) - - -def qwtToPolylineFiltered(xMap, yMap, series, from_, to, round_, - Polygon, Point): - polyline = Polygon(to-from_+1) - pointer = polyline.data() - dtype = np.float if Polygon is QPolygonF else np.int - pointer.setsize(2*polyline.size()*np.finfo(dtype).dtype.itemsize) - memory = np.frombuffer(pointer, dtype) - memory[from_*2:to*2+1:2] =\ - np.round(xMap.transform(series.xData()))[from_:to+1] - memory[from_*2+1:to*2+2:2] =\ - np.round(yMap.transform(series.yData()))[from_:to+1] - return polyline -# points = polyline.data() -# sample0 = series.sample(from_) -# points[0].setX(round_(xMap.transform(sample0.x()))) -# points[0].setY(round_(yMap.transform(sample0.y()))) -# pos = 0 -# for i in range(from_, to+1): -# sample = series.sample(i) -# p = Point(round_(xMap.transform(sample.x())), -# round_(yMap.transform(sample.y()))) -# if points[pos] != p: -# pos += 1 -# points[pos] = p -# polyline.resize(pos+1) -# return polyline - -def qwtToPolylineFilteredI(xMap, yMap, series, from_, to): - return qwtToPolylineFiltered(xMap, yMap, series, from_, to, round, - QPolygon, QPoint) - -def qwtToPolylineFilteredF(xMap, yMap, series, from_, to, round_): - return qwtToPolylineFiltered(xMap, yMap, series, from_, to, round_, - QPolygonF, QPointF) - - -def qwtToPointsFiltered(boundingRect, xMap, yMap, series, from_, to, - Polygon): - Point = QPointF if isinstance(Polygon, QPolygonF) else QPoint - if isinstance(boundingRect, QRectF): - pixelMatrix = QwtPixelMatrix(boundingRect.toAlignedRect()) - else: - pixelMatrix = QwtPixelMatrix(boundingRect) - points = [] - for i in range(from_, to+1): - sample = series.sample(i) - x = int(round(xMap.transform(sample.x()))) - y = int(round(yMap.transform(sample.y()))) - if pixelMatrix.testAndSetPixel(x, y, True) == False: - points.append(Point(x, y)) - return Polygon(list(points)) - -def qwtToPointsFilteredI(boundingRect, xMap, yMap, series, from_, to): - return qwtToPointsFiltered(boundingRect, xMap, yMap, series, from_, to, - QPolygon) - -def qwtToPointsFilteredF(boundingRect, xMap, yMap, series, from_, to): - return qwtToPointsFiltered(boundingRect, xMap, yMap, series, from_, to, - QPolygonF) - - -class QwtPointMapper_PrivateData(object): - def __init__(self): - self.boundingRect = None - self.flags = 0 - - -class QwtPointMapper(object): - RoundPoints = 0x01 - WeedOutPoints = 0x02 - - def __init__(self): - self.__data = QwtPointMapper_PrivateData() - self.qwtInvalidRect = QRectF(0.0, 0.0, -1.0, -1.0) - self.setBoundingRect(self.qwtInvalidRect) - - def setFlags(self, flags): - self.__data.flags = flags - - def flags(self): - return self.__data.flags - - def setFlag(self, flag, on=True): - if on: - self.__data.flags |= flag - else: - self.__data.flags &= ~flag - - def testFlag(self, flag): - return self.__data.flags & flag - - def setBoundingRect(self, rect): - self.__data.boundingRect = rect - - def boundingRect(self): - return self.__data.boundingRect - - def toPolygonF(self, xMap, yMap, series, from_, to): - round_ = round - no_round = lambda x: x - if self.__data.flags & QwtPointMapper.WeedOutPoints: - if self.__data.flags & QwtPointMapper.RoundPoints: - polyline = qwtToPolylineFilteredF(xMap, yMap, series, - from_, to, round_) - else: - polyline = qwtToPolylineFilteredF(xMap, yMap, series, - from_, to, no_round) - else: - if self.__data.flags & QwtPointMapper.RoundPoints: - polyline = qwtToPointsF(self.qwtInvalidRect, xMap, yMap, - series, from_, to, round_) - else: - polyline = qwtToPointsF(self.qwtInvalidRect, xMap, yMap, - series, from_, to, no_round) - return polyline - - def toPolygon(self, xMap, yMap, series, from_, to): - if self.__data.flags & QwtPointMapper.WeedOutPoints: - polyline = qwtToPolylineFilteredI(xMap, yMap, series, from_, to) - else: - polyline = qwtToPointsI(self.qwtInvalidRect, xMap, yMap, - series, from_, to) - return polyline - - def toPointsF(self, xMap, yMap, series, from_, to): - round_ = round - no_round = lambda x: x - if self.__data.flags & QwtPointMapper.WeedOutPoints: - if self.__data.flags & QwtPointMapper.RoundPoints: - if self.__data.boundingRect.isValid(): - points = qwtToPointsFilteredF(self.__data.boundingRect, - xMap, yMap, series, from_, to) - else: - points = qwtToPolylineFilteredF(xMap, yMap, series, - from_, to, round_) - else: - points = qwtToPolylineFilteredF(xMap, yMap, series, - from_, to, no_round) - else: - if self.__data.flags & QwtPointMapper.RoundPoints: - points = qwtToPointsF(self.__data.boundingRect, - xMap, yMap, series, from_, to, round_) - else: - points = qwtToPointsF(self.__data.boundingRect, - xMap, yMap, series, from_, to, no_round) - return points - - def toPoints(self, xMap, yMap, series, from_, to): - if self.__data.flags & QwtPointMapper.WeedOutPoints: - if self.__data.boundingRect.isValid(): - points = qwtToPointsFilteredI(self.__data.boundingRect, - xMap, yMap, series, from_, to) - else: - points = qwtToPolylineFilteredI(xMap, yMap, series, from_, to) - else: - points = qwtToPointsI(self.__data.boundingRect, xMap, yMap, - series, from_, to) - return points - - def toImage(self, xMap, yMap, series, from_, to, pen, antialiased, - numThreads): - if USE_THREADS: - if numThreads == 0: - numThreads = QThread.idealThreadCount() - if numThreads <= 0: - numThreads = 1 - rect = self.__data.boundingRect.toAlignedRect() - image = QImage(rect.size(), QImage.Format_ARGB32) - image.fill(Qt.transparent) - if pen.width() <= 1 and pen.color().alpha() == 255: - command = QwtDotsCommand() - command.series = series - command.rgb = pen.color().rgba() - if USE_THREADS: - numPoints = int((to-from_+1)/numThreads) - futures = [] - for i in range(numThreads): - pos = rect.topLeft() - index0 = from_ + i*numPoints - if i == numThreads-1: - command.from_ = index0 - command.to = to - qwtRenderDots(xMap, yMap, command, pos, image) - else: - command.from_ = index0 - command.to = index0 + numPoints - 1 - futures += [QtConcurrent.run(qwtRenderDots, - xMap, yMap, command, pos, image)] - for future in futures: - future.waitForFinished() - else: - command.from_ = from_ - command.to = to - qwtRenderDots(xMap, yMap, command, rect.topLeft(), image) - else: - painter = QPainter(image) - painter.setPen(pen) - painter.setRenderHint(QPainter.Antialiasing, antialiased) - chunkSize = 1000 - for i in range(chunkSize): - indexTo = min([i+chunkSize-1, to]) - points = self.toPoints(xMap, yMap, series, i, indexTo) - painter.drawPoints(points) - return image diff --git a/qwt/py3compat.py b/qwt/py3compat.py deleted file mode 100644 index a1929a0..0000000 --- a/qwt/py3compat.py +++ /dev/null @@ -1,230 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2012-2013 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -""" -guidata.py3compat (exact copy of spyderlib.py3compat) ------------------------------------------------------ - -Transitional module providing compatibility functions intended to help -migrating from Python 2 to Python 3. - -This module should be fully compatible with: - * Python >=v2.6 - * Python 3 -""" - -from __future__ import print_function - -import sys -import os - -PY2 = sys.version[0] == '2' -PY3 = sys.version[0] == '3' - - -#============================================================================== -# Data types -#============================================================================== -if PY2: - # Python 2 - TEXT_TYPES = (str, unicode) - INT_TYPES = (int, long) -else: - # Python 3 - TEXT_TYPES = (str,) - INT_TYPES = (int,) -NUMERIC_TYPES = tuple(list(INT_TYPES) + [float, complex]) - - -#============================================================================== -# Renamed/Reorganized modules -#============================================================================== -if PY2: - # Python 2 - import __builtin__ as builtins - import ConfigParser as configparser - try: - import _winreg as winreg - except ImportError: - pass - from sys import maxint as maxsize - try: - import CStringIO as io - except ImportError: - import StringIO as io - try: - import cPickle as pickle - except ImportError: - import pickle - from UserDict import DictMixin as MutableMapping -else: - # Python 3 - import builtins - import configparser - try: - import winreg - except ImportError: - pass - from sys import maxsize - import io - import pickle - from collections import MutableMapping - - -#============================================================================== -# Strings -#============================================================================== -def is_text_string(obj): - """Return True if `obj` is a text string, False if it is anything else, - like binary data (Python 3) or QString (Python 2, PyQt API #1)""" - if PY2: - # Python 2 - return isinstance(obj, basestring) - else: - # Python 3 - return isinstance(obj, str) - -def is_binary_string(obj): - """Return True if `obj` is a binary string, False if it is anything else""" - if PY2: - # Python 2 - return isinstance(obj, str) - else: - # Python 3 - return isinstance(obj, bytes) - -def is_string(obj): - """Return True if `obj` is a text or binary Python string object, - False if it is anything else, like a QString (Python 2, PyQt API #1)""" - return is_text_string(obj) or is_binary_string(obj) - -def is_unicode(obj): - """Return True if `obj` is unicode""" - if PY2: - # Python 2 - return isinstance(obj, unicode) - else: - # Python 3 - return isinstance(obj, str) - -def to_text_string(obj, encoding=None): - """Convert `obj` to (unicode) text string""" - if PY2: - # Python 2 - if encoding is None: - return unicode(obj) - else: - return unicode(obj, encoding) - else: - # Python 3 - if encoding is None: - return str(obj) - elif isinstance(obj, str): - # In case this function is not used properly, this could happen - return obj - else: - return str(obj, encoding) - -def to_binary_string(obj, encoding=None): - """Convert `obj` to binary string (bytes in Python 3, str in Python 2)""" - if PY2: - # Python 2 - if encoding is None: - return str(obj) - else: - return obj.encode(encoding) - else: - # Python 3 - return bytes(obj, 'utf-8' if encoding is None else encoding) - - -#============================================================================== -# Function attributes -#============================================================================== -def get_func_code(func): - """Return function code object""" - if PY2: - # Python 2 - return func.func_code - else: - # Python 3 - return func.__code__ - -def get_func_name(func): - """Return function name""" - if PY2: - # Python 2 - return func.func_name - else: - # Python 3 - return func.__name__ - -def get_func_defaults(func): - """Return function default argument values""" - if PY2: - # Python 2 - return func.func_defaults - else: - # Python 3 - return func.__defaults__ - - -#============================================================================== -# Special method attributes -#============================================================================== -def get_meth_func(obj): - """Return method function object""" - if PY2: - # Python 2 - return obj.im_func - else: - # Python 3 - return obj.__func__ - -def get_meth_class_inst(obj): - """Return method class instance""" - if PY2: - # Python 2 - return obj.im_self - else: - # Python 3 - return obj.__self__ - -def get_meth_class(obj): - """Return method class""" - if PY2: - # Python 2 - return obj.im_class - else: - # Python 3 - return obj.__self__.__class__ - - -#============================================================================== -# Misc. -#============================================================================== -if PY2: - # Python 2 - input = raw_input - getcwd = os.getcwdu - cmp = cmp - import string - str_lower = string.lower -else: - # Python 3 - input = input - getcwd = os.getcwd - def cmp(a, b): - return (a > b) - (a < b) - str_lower = str.lower - -def qbytearray_to_str(qba): - """Convert QByteArray object to str in a way compatible with Python 2/3""" - return str(bytes(qba.toHex()).decode()) - - -if __name__ == '__main__': - pass diff --git a/qwt/qt/QtCore.py b/qwt/qt/QtCore.py deleted file mode 100644 index e43f631..0000000 --- a/qwt/qt/QtCore.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2011 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -import os - -if os.environ['QT_API'] == 'pyqt5': - from PyQt5.QtCore import * # analysis:ignore - from PyQt5.QtCore import QCoreApplication - from PyQt5.QtCore import pyqtSignal as Signal - from PyQt5.QtCore import pyqtSlot as Slot - from PyQt5.QtCore import pyqtProperty as Property - from PyQt5.QtCore import QT_VERSION_STR as __version__ -elif os.environ['QT_API'] == 'pyqt': - from PyQt4.QtCore import * # analysis:ignore - from PyQt4.Qt import QCoreApplication # analysis:ignore - from PyQt4.Qt import Qt # analysis:ignore - from PyQt4.QtCore import pyqtSignal as Signal # analysis:ignore - from PyQt4.QtCore import pyqtSlot as Slot # analysis:ignore - from PyQt4.QtCore import pyqtProperty as Property # analysis:ignore - from PyQt4.QtCore import QT_VERSION_STR as __version__ # analysis:ignore - # Forces new modules written by PyQt4 developers to be PyQt5-compatible - del SIGNAL, SLOT -else: - import PySide.QtCore - __version__ = PySide.QtCore.__version__ # analysis:ignore - from PySide.QtCore import * # analysis:ignore diff --git a/qwt/qt/QtGui.py b/qwt/qt/QtGui.py deleted file mode 100644 index 9082327..0000000 --- a/qwt/qt/QtGui.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2011 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -import os - -if os.environ['QT_API'] == 'pyqt5': - from PyQt5.QtCore import QSortFilterProxyModel # analysis:ignore - from PyQt5.QtPrintSupport import (QPrinter, QPrintDialog, # analysis:ignore - QAbstractPrintDialog) - from PyQt5.QtPrintSupport import QPrintPreviewDialog # analysis:ignore - from PyQt5.QtGui import * # analysis:ignore - from PyQt5.QtWidgets import * # analysis:ignore -elif os.environ['QT_API'] == 'pyqt': - from PyQt4.Qt import QKeySequence, QTextCursor # analysis:ignore - from PyQt4.QtGui import * # analysis:ignore -else: - from PySide.QtGui import * # analysis:ignore diff --git a/qwt/qt/QtSvg.py b/qwt/qt/QtSvg.py deleted file mode 100644 index c142165..0000000 --- a/qwt/qt/QtSvg.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2012 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -import os - -if os.environ['QT_API'] == 'pyqt5': - from PyQt5.QtSvg import * # analysis:ignore -elif os.environ['QT_API'] == 'pyqt': - from PyQt4.QtSvg import * # analysis:ignore -else: - from PySide.QtSvg import * # analysis:ignore diff --git a/qwt/qt/QtWebKit.py b/qwt/qt/QtWebKit.py deleted file mode 100644 index e0ed4a8..0000000 --- a/qwt/qt/QtWebKit.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2011 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -import os - -if os.environ['QT_API'] == 'pyqt5': - from PyQt5.QtWebKitWidgets import QWebPage, QWebView # analysis:ignore - from PyQt5.QtWebKit import QWebSettings # analysis:ignore -elif os.environ['QT_API'] == 'pyqt': - from PyQt4.QtWebKit import (QWebPage, QWebView, # analysis:ignore - QWebSettings) -else: - from PySide.QtWebKit import * # analysis:ignore diff --git a/qwt/qt/__init__.py b/qwt/qt/__init__.py deleted file mode 100644 index 13b421a..0000000 --- a/qwt/qt/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2011-2012 Pierre Raybaut -# © 2012-2014 anatoly techtonik -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -"""Transitional package (PyQt4 --> PySide)""" - -import os - -os.environ.setdefault('QT_API', 'pyqt') -assert os.environ['QT_API'] in ('pyqt5', 'pyqt', 'pyside') - -API = os.environ['QT_API'] -API_NAME = {'pyqt5': 'PyQt5', 'pyqt': 'PyQt4', 'pyside': 'PySide'}[API] - -PYQT5 = False - -if API == 'pyqt5': - try: - from PyQt5.QtCore import PYQT_VERSION_STR as __version__ - is_old_pyqt = False - is_pyqt46 = False - PYQT5 = True - except ImportError: - pass -elif API == 'pyqt': - # Spyder 2.3 is compatible with both #1 and #2 PyQt API, - # but to avoid issues with IPython and other Qt plugins - # we choose to support only API #2 for 2.4+ - import sip - try: - sip.setapi('QString', 2) - sip.setapi('QVariant', 2) - sip.setapi('QDate', 2) - sip.setapi('QDateTime', 2) - sip.setapi('QTextStream', 2) - sip.setapi('QTime', 2) - sip.setapi('QUrl', 2) - except AttributeError: - # PyQt < v4.6. The actual check is done by requirements.check_qt() - # call from spyder.py - pass - - try: - from PyQt4.QtCore import PYQT_VERSION_STR as __version__ # analysis:ignore - except ImportError: - # Switching to PySide - API = os.environ['QT_API'] = 'pyside' - API_NAME = 'PySide' - else: - is_old_pyqt = __version__.startswith(('4.4', '4.5', '4.6', '4.7')) - is_pyqt46 = __version__.startswith('4.6') - import sip - try: - API_NAME += (" (API v%d)" % sip.getapi('QString')) - except AttributeError: - pass - - -if API == 'pyside': - try: - from PySide import __version__ # analysis:ignore - except ImportError: - raise ImportError("Spyder requires PySide or PyQt to be installed") - else: - is_old_pyqt = is_pyqt46 = False diff --git a/qwt/qt/compat.py b/qwt/qt/compat.py deleted file mode 100644 index 23ffcf3..0000000 --- a/qwt/qt/compat.py +++ /dev/null @@ -1,209 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright © 2011-2012 Pierre Raybaut -# Licensed under the terms of the MIT License -# (see LICENSE file for details) - -""" -spyderlib.qt.compat -------------------- - -Transitional module providing compatibility functions intended to help -migrating from PyQt to PySide. - -This module should be fully compatible with: - * PyQt >=v4.4 - * both PyQt API #1 and API #2 - * PySide -""" - -from __future__ import print_function - -import os -import sys -import collections - -from qwt.qt.QtGui import QFileDialog -from qwt.py3compat import is_text_string, to_text_string, TEXT_TYPES - -#============================================================================== -# QVariant conversion utilities -#============================================================================== - -PYQT_API_1 = False -if os.environ['QT_API'] == 'pyqt': - import sip - try: - PYQT_API_1 = sip.getapi('QVariant') == 1 # PyQt API #1 - except AttributeError: - # PyQt =v4.4 (API #1 and #2) and PySide >=v1.0""" - # Calling QFileDialog static method - if sys.platform == "win32": - # On Windows platforms: redirect standard outputs - _temp1, _temp2 = sys.stdout, sys.stderr - sys.stdout, sys.stderr = None, None - try: - result = QFileDialog.getExistingDirectory(parent, caption, basedir, - options) - finally: - if sys.platform == "win32": - # On Windows platforms: restore standard outputs - sys.stdout, sys.stderr = _temp1, _temp2 - if not is_text_string(result): - # PyQt API #1 - result = to_text_string(result) - return result - -def _qfiledialog_wrapper(attr, parent=None, caption='', basedir='', - filters='', selectedfilter='', options=None): - if options is None: - options = QFileDialog.Options(0) - try: - # PyQt =v4.6 - QString = None # analysis:ignore - tuple_returned = True - try: - # PyQt >=v4.6 - func = getattr(QFileDialog, attr+'AndFilter') - except AttributeError: - # PySide or PyQt =v4.6 - output, selectedfilter = result - else: - # PyQt =v4.4 (API #1 and #2) and PySide >=v1.0""" - return _qfiledialog_wrapper('getOpenFileName', parent=parent, - caption=caption, basedir=basedir, - filters=filters, selectedfilter=selectedfilter, - options=options) - -def getopenfilenames(parent=None, caption='', basedir='', filters='', - selectedfilter='', options=None): - """Wrapper around QtGui.QFileDialog.getOpenFileNames static method - Returns a tuple (filenames, selectedfilter) -- when dialog box is canceled, - returns a tuple (empty list, empty string) - Compatible with PyQt >=v4.4 (API #1 and #2) and PySide >=v1.0""" - return _qfiledialog_wrapper('getOpenFileNames', parent=parent, - caption=caption, basedir=basedir, - filters=filters, selectedfilter=selectedfilter, - options=options) - -def getsavefilename(parent=None, caption='', basedir='', filters='', - selectedfilter='', options=None): - """Wrapper around QtGui.QFileDialog.getSaveFileName static method - Returns a tuple (filename, selectedfilter) -- when dialog box is canceled, - returns a tuple of empty strings - Compatible with PyQt >=v4.4 (API #1 and #2) and PySide >=v1.0""" - return _qfiledialog_wrapper('getSaveFileName', parent=parent, - caption=caption, basedir=basedir, - filters=filters, selectedfilter=selectedfilter, - options=options) - -if __name__ == '__main__': - from spyderlib.utils.qthelpers import qapplication - _app = qapplication() - print(repr(getexistingdirectory())) - print(repr(getopenfilename(filters='*.py;;*.txt'))) - print(repr(getopenfilenames(filters='*.py;;*.txt'))) - print(repr(getsavefilename(filters='*.py;;*.txt'))) - sys.exit() diff --git a/qwt/qthelpers.py b/qwt/qthelpers.py new file mode 100644 index 0000000..283ccf1 --- /dev/null +++ b/qwt/qthelpers.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +"""Qt helpers""" + +import os + +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +QT_API = os.environ["QT_API"] + + +def qcolor_from_str(color, default): + """Return QColor object from str + + :param color: Input color + :type color: QColor or str or None + :param QColor default: Default color (returned if color is None) + + If color is already a QColor instance, simply return color. + If color is None, return default color. + If color is neither an str nor a QColor instance nor None, raise TypeError. + """ + if color is None: + return default + elif isinstance(color, str): + try: + return getattr(QC.Qt, color) + except AttributeError: + raise ValueError("Unknown Qt color %r" % color) + else: + try: + return QG.QColor(color) + except TypeError: + raise TypeError("Invalid color %r" % color) + + +def take_screenshot(widget, path, size=None, quit=True): + """Take screenshot of widget""" + if size is not None: + widget.resize(*size) + widget.show() + QW.QApplication.processEvents() + pixmap = widget.grab() + pixmap.save(path) + if quit: + QC.QTimer.singleShot(0, QW.QApplication.instance().quit) diff --git a/qwt/sample.py b/qwt/sample.py deleted file mode 100644 index fbc2924..0000000 --- a/qwt/sample.py +++ /dev/null @@ -1,82 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.interval import QwtInterval - - -class QwtIntervalSample(object): - def __init__(self, *args): - if len(args) == 0: - self.value = 0. - self.interval = QwtInterval() - elif len(args) == 2: - v, intv = args - self.value = v - self.interval = intv - elif len(args) == 3: - v, min_, max_ = args - self.value = v - self.interval = QwtInterval(min_, max_) - else: - raise TypeError("%s() takes 0, 2 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def __eq__(self, other): - return self.value == other.value and self.interval == other.interval - - def __ne__(self, other): - return not (self == other) - - -class QwtSetSample(object): - def __init__(self, *args): - if len(args) == 0: - self.value = 0 - self.set = [] - elif len(args) == 2: - v, s = args - self.value = v - self.set = s - else: - raise TypeError("%s() takes 0 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def __eq__(self, other): - return self.value == other.value and self.set == other.set - - def __ne__(self, other): - return not (self == other) - - def added(self): - return sum(self.set) - - -class QwtOHLCSample(object): - def __init__(self, time=0., open_=0., high=0., low=0., close=0.): - self.time = time - self.open = open_ - self.high = high - self.low = low - self.close = close - - def isValid(self): - return self.low <= self.high and self.open >= self.low and\ - self.open <= self.high and self.close >= self.low and\ - self.close <= self.high - - def boundingInterval(self): - minY = self.open - minY = min([minY, self.high]) - minY = min([minY, self.low]) - minY = min([minY, self.close]) - maxY = self.open - maxY = max([maxY, self.high]) - maxY = max([maxY, self.low]) - maxY = max([maxY, self.close]) - return QwtInterval(minY, maxY) - - diff --git a/qwt/scale_div.py b/qwt/scale_div.py index 10b6d18..9ff2e5e 100644 --- a/qwt/scale_div.py +++ b/qwt/scale_div.py @@ -5,16 +5,85 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.interval import QwtInterval +""" +QwtScaleDiv +----------- + +.. autoclass:: QwtScaleDiv + :members: +""" import copy +from qwt.interval import QwtInterval + class QwtScaleDiv(object): + """ + A class representing a scale division + + A Qwt scale is defined by its boundaries and 3 list + for the positions of the major, medium and minor ticks. + + The `upperLimit()` might be smaller than the `lowerLimit()` + to indicate inverted scales. + + Scale divisions can be calculated from a `QwtScaleEngine`. + + .. seealso:: + + :py:meth:`qwt.scale_engine.QwtScaleEngine.divideScale()`, + :py:meth:`qwt.plot.QwtPlot.setAxisScaleDiv()` + + Scale tick types: + + * `QwtScaleDiv.NoTick`: No ticks + * `QwtScaleDiv.MinorTick`: Minor ticks + * `QwtScaleDiv.MediumTick`: Medium ticks + * `QwtScaleDiv.MajorTick`: Major ticks + * `QwtScaleDiv.NTickTypes`: Number of valid tick types + + .. py:class:: QwtScaleDiv() + + Basic constructor. Lower bound = Upper bound = 0. + + .. py:class:: QwtScaleDiv(interval, ticks) + :noindex: + + :param qwt.interval.QwtInterval interval: Interval + :param list ticks: list of major, medium and minor ticks + + .. py:class:: QwtScaleDiv(lowerBound, upperBound) + :noindex: + + :param float lowerBound: First boundary + :param float upperBound: Second boundary + + .. py:class:: QwtScaleDiv(lowerBound, upperBound, ticks) + :noindex: + + :param float lowerBound: First boundary + :param float upperBound: Second boundary + :param list ticks: list of major, medium and minor ticks + + .. py:class:: QwtScaleDiv(lowerBound, upperBound, minorTicks, mediumTicks, majorTicks) + :noindex: + + :param float lowerBound: First boundary + :param float upperBound: Second boundary + :param list minorTicks: list of minor ticks + :param list mediumTicks: list of medium ticks + :param list majorTicks: list of major ticks + + .. note:: + + lowerBound might be greater than upperBound for inverted scales + """ + # enum TickType NoTick = -1 MinorTick, MediumTick, MajorTick, NTickTypes = list(range(4)) - + def __init__(self, *args): self.__ticks = None if len(args) == 2 and isinstance(args[1], (tuple, list)): @@ -27,95 +96,220 @@ def __init__(self, *args): elif len(args) == 3: self.__lowerBound, self.__upperBound, ticks = args self.__ticks = ticks[:] - elif len(args) == 4: - (self.__lowerBound, self.__upperBound, - minorTicks, mediumTicks, majorTicks) = args + elif len(args) == 5: + ( + self.__lowerBound, + self.__upperBound, + minorTicks, + mediumTicks, + majorTicks, + ) = args + self.__ticks = [0] * self.NTickTypes self.__ticks[self.MinorTick] = minorTicks self.__ticks[self.MediumTick] = mediumTicks self.__ticks[self.MajorTick] = majorTicks elif len(args) == 0: - self.__lowerBound, self.__upperBound = 0., 0. + self.__lowerBound, self.__upperBound = 0.0, 0.0 else: - raise TypeError("%s() takes 0, 2, 3 or 4 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s() takes 0, 2, 3 or 5 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + def setInterval(self, *args): + """ + Change the interval + + .. py:method:: setInterval(lowerBound, upperBound) + :noindex: + + :param float lowerBound: First boundary + :param float upperBound: Second boundary + + .. py:method:: setInterval(interval) + :noindex: + + :param qwt.interval.QwtInterval interval: Interval + + .. note:: + + lowerBound might be greater than upperBound for inverted scales + """ if len(args) == 2: self.__lowerBound, self.__upperBound = args elif len(args) == 1: - interval, = args + (interval,) = args self.__lowerBound = interval.minValue() self.__upperBound = interval.maxValue() else: - raise TypeError("%s().setInterval() takes 1 or 2 argument(s) (%s "\ - "given)" % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s().setInterval() takes 1 or 2 argument(s) (%s " + "given)" % (self.__class__.__name__, len(args)) + ) + def interval(self): + """ + :return: Interval + """ return QwtInterval(self.__lowerBound, self.__upperBound) - + def setLowerBound(self, lowerBound): + """ + Set the first boundary + + :param float lowerBound: First boundary + + .. seealso:: + + :py:meth:`lowerBound()`, :py:meth:`setUpperBound()` + """ self.__lowerBound = lowerBound - + def lowerBound(self): + """ + :return: the first boundary + + .. seealso:: + + :py:meth:`upperBound()` + """ return self.__lowerBound - + def setUpperBound(self, upperBound): + """ + Set the second boundary + + :param float lowerBound: Second boundary + + .. seealso:: + + :py:meth:`upperBound()`, :py:meth:`setLowerBound()` + """ self.__upperBound = upperBound - + def upperBound(self): + """ + :return: the second boundary + + .. seealso:: + + :py:meth:`lowerBound()` + """ return self.__upperBound - + def range(self): + """ + :return: upperBound() - lowerBound() + """ return self.__upperBound - self.__lowerBound - + def __eq__(self, other): if self.__ticks is None: return False - if self.__lowerBound != other.__lowerBound or\ - self.__upperBound != other.__upperBound: + if ( + self.__lowerBound != other.__lowerBound + or self.__upperBound != other.__upperBound + ): return False return self.__ticks == other.__ticks - + def __ne__(self, other): return not self.__eq__(other) - + def isEmpty(self): + """ + Check if the scale division is empty( lowerBound() == upperBound() ) + """ return self.__lowerBound == self.__upperBound - + def isIncreasing(self): + """ + Check if the scale division is increasing( lowerBound() <= upperBound() ) + """ return self.__lowerBound <= self.__upperBound - + def contains(self, value): - min_ = min([self.__lowerBound, self.__upperBound]) - max_ = max([self.__lowerBound, self.__upperBound]) - return value >= min_ and value <= max_ - + """ + Return if a value is between lowerBound() and upperBound() + + :param float value: Value + :return: True/False + """ + lb = self.__lowerBound + ub = self.__upperBound + if lb <= ub: + return lb <= value <= ub + return ub <= value <= lb + def invert(self): - (self.__lowerBound, - self.__upperBound) = self.__upperBound, self.__lowerBound + """ + Invert the scale division + + .. seealso:: + + :py:meth:`inverted()` + """ + (self.__lowerBound, self.__upperBound) = self.__upperBound, self.__lowerBound for index in range(self.NTickTypes): self.__ticks[index].reverse() - + def inverted(self): + """ + :return: A scale division with inverted boundaries and ticks + + .. seealso:: + + :py:meth:`invert()` + """ other = copy.deepcopy(self) other.invert() return other - + def bounded(self, lowerBound, upperBound): + """ + Return a scale division with an interval [lowerBound, upperBound] + where all ticks outside this interval are removed + + :param float lowerBound: First boundary + :param float lowerBound: Second boundary + :return: Scale division with all ticks inside of the given interval + + .. note:: + + lowerBound might be greater than upperBound for inverted scales + """ min_ = min([self.__lowerBound, self.__upperBound]) max_ = max([self.__lowerBound, self.__upperBound]) sd = QwtScaleDiv() sd.setInterval(lowerBound, upperBound) for tickType in range(self.NTickTypes): - sd.setTicks(tickType, [tick for tick in self.__ticks[tickType] - if tick >= min_ and tick <= max_]) + sd.setTicks( + tickType, + [ + tick + for tick in self.__ticks[tickType] + if tick >= min_ and tick <= max_ + ], + ) return sd - + def setTicks(self, tickType, ticks): + """ + Assign ticks + + :param int type: MinorTick, MediumTick or MajorTick + :param list ticks: Values of the tick positions + """ if tickType in range(self.NTickTypes): self.__ticks[tickType] = ticks - + def ticks(self, tickType): + """ + Return a list of ticks + + :param int type: MinorTick, MediumTick or MajorTick + :return: Tick list + """ if self.__ticks is not None and tickType in range(self.NTickTypes): return self.__ticks[tickType] else: diff --git a/qwt/scale_draw.py b/qwt/scale_draw.py index 1f7ed6c..dd049ac 100644 --- a/qwt/scale_draw.py +++ b/qwt/scale_draw.py @@ -1,572 +1,1342 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.scale_div import QwtScaleDiv -from qwt.scale_map import QwtScaleMap -from qwt.text import QwtText -from qwt.math import qwtRadians -from qwt.painter import QwtPainter - -from qwt.qt.QtGui import QPalette, QFontMetrics, QTransform -from qwt.qt.QtCore import (Qt, qFuzzyCompare, QLocale, QRectF, QPointF, QRect, - QPoint) - -import numpy as np - - -class QwtAbstractScaleDraw_PrivateData(object): - def __init__(self): - self.spacing = 4 - self.penWidth = 0 - self.minExtent = 0. - - self.components = QwtAbstractScaleDraw.Backbone|\ - QwtAbstractScaleDraw.Ticks|\ - QwtAbstractScaleDraw.Labels - self.tickLength = [None] * 3 - self.tickLength[QwtScaleDiv.MinorTick] = 4.0 - self.tickLength[QwtScaleDiv.MediumTick] = 6.0 - self.tickLength[QwtScaleDiv.MajorTick] = 8.0 - - self.map = QwtScaleMap() - self.scaleDiv = QwtScaleDiv() - - self.labelCache = {} - - -class QwtAbstractScaleDraw(object): - # enum ScaleComponent - Backbone = 0x01 - Ticks = 0x02 - Labels = 0x04 - def __init__(self): - self.__data = QwtAbstractScaleDraw_PrivateData() - - def enableComponent(self, component, enable): - if enable: - self.__data.components |= component - else: - self.__data.components &= ~component - - def hasComponent(self, component): - return self.__data.components & component - - def setScaleDiv(self, scaleDiv): - self.__data.scaleDiv = scaleDiv - self.__data.map.setScaleInterval(scaleDiv.lowerBound(), - scaleDiv.upperBound()) - self.__data.labelCache.clear() - - def setTransformation(self, transformation): - self.__data.map.setTransformation(transformation) - - def scaleMap(self): - return self.__data.map - - def scaleDiv(self): - return self.__data.scaleDiv - - def setPenWidth(self, width): - if width < 0: - width = 0 - if width != self.__data.penWidth: - self.__data.penWidth = width - - def penWidth(self): - return self.__data.penWidth - - def draw(self, painter, palette): - painter.save() - - pen = painter.pen() - pen.setWidth(self.__data.penWidth) - pen.setCosmetic(False) - painter.setPen(pen) - - if self.hasComponent(QwtAbstractScaleDraw.Labels): - painter.save() - painter.setPen(palette.color(QPalette.Text)) - majorTicks = self.__data.scaleDiv.ticks(QwtScaleDiv.MajorTick) - for v in majorTicks: - if self.__data.scaleDiv.contains(v): - self.drawLabel(painter, v) - painter.restore() - - if self.hasComponent(QwtAbstractScaleDraw.Ticks): - painter.save() - pen = painter.pen() - pen.setColor(palette.color(QPalette.WindowText)) - pen.setCapStyle(Qt.FlatCap) - painter.setPen(pen) - for tickType in range(QwtScaleDiv.NTickTypes): - tickLen = self.__data.tickLength[tickType] - if tickLen <= 0.: - continue - ticks = self.__data.scaleDiv.ticks(tickType) - for v in ticks: - if self.__data.scaleDiv.contains(v): - self.drawTick(painter, v, tickLen) - painter.restore() - - if self.hasComponent(QwtAbstractScaleDraw.Backbone): - painter.save() - pen = painter.pen() - pen.setColor(palette.color(QPalette.WindowText)) - pen.setCapStyle(Qt.FlatCap) - painter.setPen(pen) - self.drawBackbone(painter) - painter.restore() - - painter.restore() - - def setSpacing(self, spacing): - if spacing < 0: - spacing = 0 - self.__data.spacing = spacing - - def spacing(self): - return self.__data.spacing - - def setMinimumExtent(self, minExtent): - if minExtent < 0.: - minExtent = 0. - self.__data.minExtent = minExtent - - def minimumExtent(self): - return self.__data.minExtent - - def setTickLength(self, tickType, length): - if tickType < QwtScaleDiv.MinorTick or\ - tickType > QwtScaleDiv.MajorTick: - return - if length < 0.: - length = 0. - maxTickLen = 1000. - if length > maxTickLen: - length = maxTickLen - self.__data.tickLength[tickType] = length - - def tickLength(self, tickType): - if tickType < QwtScaleDiv.MinorTick or\ - tickType > QwtScaleDiv.MajorTick: - return 0 - return self.__data.tickLength[tickType] - - def maxTickLength(self): - length = 0. - for tickType in range(QwtScaleDiv.NTickTypes): - length = max([length, self.__data.tickLength[tickType]]) - return length - - def label(self, value): - return QLocale().toString(value) - - def tickLabel(self, font, value): - lbl = self.__data.labelCache.get(value) - if lbl is None: - lbl = QwtText(self.label(value)) - lbl.setRenderFlags(0) - lbl.setLayoutAttribute(QwtText.MinimumLayout) - lbl.textSize(font) - self.__data.labelCache[value] = lbl - return lbl - - - -class QwtScaleDraw_PrivateData(object): - def __init__(self): - self.len = 0 - self.alignment = QwtScaleDraw.BottomScale - self.labelAlignment = 0 - self.labelRotation = 0. - - self.pos = QPointF() - - -class QwtScaleDraw(QwtAbstractScaleDraw): - # enum Alignment - BottomScale, TopScale, LeftScale, RightScale = list(range(4)) - Flags = ( - Qt.AlignHCenter|Qt.AlignBottom, # BottomScale - Qt.AlignHCenter|Qt.AlignTop, # TopScale - Qt.AlignLeft|Qt.AlignVCenter, # LeftScale - Qt.AlignRight|Qt.AlignVCenter, # RightScale - ) - - def __init__(self): - QwtAbstractScaleDraw.__init__(self) - self.__data = QwtScaleDraw_PrivateData() - self.setLength(100) - - def alignment(self): - return self.__data.alignment - - def setAlignment(self, align): - self.__data.alignment = align - - def orientation(self): - if self.__data.alignment in (self.TopScale, self.BottomScale): - return Qt.Horizontal - elif self.__data.alignment in (self.LeftScale, self.RightScale): - return Qt.Vertical - - def getBorderDistHint(self, font): - start, end = 0, 1. - - if not self.hasComponent(QwtAbstractScaleDraw.Labels): - return start, end - - ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) - if len(ticks) == 0: - return start, end - - minTick = ticks[0] - minPos = self.scaleMap().transform(minTick) - maxTick = minTick - maxPos = minPos - - for tick in ticks: - tickPos = self.scaleMap().transform(tick) - if tickPos < minPos: - minTick = tick - minPos = tickPos - if tickPos > self.scaleMap().transform(maxTick): - maxTick = tick - maxPos = tickPos - - s = 0. - e = 0. - if self.orientation() == Qt.Vertical: - s = -self.labelRect(font, minTick).top() - s -= abs(minPos - round(self.scaleMap().p2())) - - e = self.labelRect(font, maxTick).bottom() - e -= abs(maxPos - self.scaleMap().p1()) - else: - s = -self.labelRect(font, minTick).left() - s -= abs(minPos - self.scaleMap().p1()) - - e = self.labelRect(font, maxTick).right() - e -= abs(maxPos - self.scaleMap().p2()) - - start, end = np.ceil(np.nan_to_num(np.array([s, e])).clip(0, None)) - return start, end - - def minLabelDist(self, font): - if not self.hasComponent(QwtAbstractScaleDraw.Labels): - return 0 - - ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) - if not ticks: - return 0 - - fm = QFontMetrics(font) - vertical = self.orientation() == Qt.Vertical - - bRect1 = QRectF() - bRect2 = self.labelRect(font, ticks[0]) - if vertical: - bRect2.setRect(-bRect2.bottom(), 0., - bRect2.height(), bRect2.width()) - - maxDist = 0. - - for tick in ticks: - bRect1 = bRect2 - bRect2 = self.labelRect(font, tick) - if vertical: - bRect2.setRect(-bRect2.bottom(), 0., - bRect2.height(), bRect2.width()) - - dist = fm.leading() - if bRect1.right() > 0: - dist += bRect1.right() - if bRect2.left() < 0: - dist += -bRect2.left() - - if dist > maxDist: - maxDist = dist - - angle = qwtRadians(self.labelRotation()) - if vertical: - angle += np.pi/2 - - sinA = np.sin(angle) - if qFuzzyCompare(sinA+1., 1.): - return np.ceil(maxDist) - - fmHeight = fm.ascent()-2 - - labelDist = fmHeight/np.sin(angle)*np.cos(angle) - if labelDist < 0: - labelDist = -labelDist - - if labelDist > maxDist: - labelDist = maxDist - - if labelDist < fmHeight: - labelDist = fmHeight - - return np.ceil(labelDist) - - def extent(self, font): - d = 0. - if self.hasComponent(QwtAbstractScaleDraw.Labels): - if self.orientation() == Qt.Vertical: - d = self.maxLabelWidth(font) - else: - d = self.maxLabelHeight(font) - if d > 0: - d += self.spacing() - if self.hasComponent(QwtAbstractScaleDraw.Ticks): - d += self.maxTickLength() - if self.hasComponent(QwtAbstractScaleDraw.Backbone): - pw = max([1, self.penWidth()]) - d += pw - return max([d, self.minimumExtent()]) - - def minLength(self, font): - startDist, endDist = self.getBorderDistHint(font) - sd = self.scaleDiv() - minorCount = len(sd.ticks(QwtScaleDiv.MinorTick))+\ - len(sd.ticks(QwtScaleDiv.MediumTick)) - majorCount = len(sd.ticks(QwtScaleDiv.MajorTick)) - lengthForLabels = 0 - if self.hasComponent(QwtAbstractScaleDraw.Labels): - lengthForLabels = self.minLabelDist(font)*majorCount - lengthForTicks = 0 - if self.hasComponent(QwtAbstractScaleDraw.Ticks): - pw = max([1, self.penWidth()]) - lengthForTicks = np.ceil((majorCount+minorCount)*(pw+1.)) - return startDist + endDist + max([lengthForLabels, lengthForTicks]) - - def labelPosition(self, value): - tval = self.scaleMap().transform(value) - dist = self.spacing() - if self.hasComponent(QwtAbstractScaleDraw.Backbone): - dist += max([1, self.penWidth()]) - if self.hasComponent(QwtAbstractScaleDraw.Ticks): - dist += self.tickLength(QwtScaleDiv.MajorTick) - - px = 0 - py = 0 - if self.alignment() == self.RightScale: - px = self.__data.pos.x() + dist - py = tval - elif self.alignment() == self.LeftScale: - px = self.__data.pos.x() - dist - py = tval - elif self.alignment() == self.BottomScale: - px = tval - py = self.__data.pos.y() + dist - elif self.alignment() == self.TopScale: - px = tval - py = self.__data.pos.y() - dist - - return QPointF(px, py) - - def drawTick(self, painter, value, len_): - if len_ <= 0: - return - - roundingAlignment = QwtPainter.roundingAlignment(painter) - pos = self.__data.pos - tval = self.scaleMap().transform(value) - if roundingAlignment: - tval = round(tval) - - pw = self.penWidth() - a = 0 - if pw > 1 and roundingAlignment: - a = 1 - - if self.alignment() == self.LeftScale: - x1 = pos.x() + a - x2 = pos.x() + a - pw - len_ - if roundingAlignment: - x1 = round(x1) - x2 = round(x2) - QwtPainter.drawLine(painter, x1, tval, x2, tval) - elif self.alignment() == self.RightScale: - x1 = pos.x() - x2 = pos.x() + pw + len_ - if roundingAlignment: - x1 = round(x1) - x2 = round(x2) - QwtPainter.drawLine(painter, x1, tval, x2, tval) - elif self.alignment() == self.BottomScale: - y1 = pos.y() - y2 = pos.y() + pw + len_ - if roundingAlignment: - y1 = round(y1) - y2 = round(y2) - QwtPainter.drawLine(painter, tval, y1, tval, y2) - elif self.alignment() == self.TopScale: - y1 = pos.y() + a - y2 = pos.y() - pw - len_ + a - if roundingAlignment: - y1 = round(y1) - y2 = round(y2) - QwtPainter.drawLine(painter, tval, y1, tval, y2) - - def drawBackbone(self, painter): - doAlign = QwtPainter.roundingAlignment(painter) - pos = self.__data.pos - len_ = self.__data.len - pw = max([self.penWidth(), 1]) - - if doAlign: - if self.alignment() in (self.LeftScale, self.TopScale): - off = (pw-1)/2 - else: - off = pw/2 - else: - off = .5*self.penWidth() - - if self.alignment() == self.LeftScale: - x = pos.x() - off - if doAlign: - x = round(x) - QwtPainter.drawLine(painter, x, pos.y(), x, pos.y()+len_) - elif self.alignment() == self.RightScale: - x = pos.x() + off - if doAlign: - x = round(x) - QwtPainter.drawLine(painter, x, pos.y(), x, pos.y()+len_) - elif self.alignment() == self.TopScale: - y = pos.y() - off - if doAlign: - y = round(y) - QwtPainter.drawLine(painter, pos.x(), y, pos.x()+len_, y) - elif self.alignment() == self.BottomScale: - y = pos.y() + off - if doAlign: - y = round(y) - QwtPainter.drawLine(painter, pos.x(), y, pos.x()+len_, y) - - def move(self, *args): - if len(args) == 2: - x, y = args - self.move(QPointF(x, y)) - elif len(args) == 1: - pos, = args - self.__data.pos = pos - self.updateMap() - else: - raise TypeError("%s().move() takes 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def pos(self): - return self.__data.pos - - def setLength(self, length): - if length >= 0 and length < 10: - length = 10 - if length < 0 and length > -10: - length = -10 - self.__data.len = length - self.updateMap() - - def length(self): - return self.__data.len - - def drawLabel(self, painter, value): - lbl = self.tickLabel(painter.font(), value) - if lbl is None or lbl.isEmpty(): - return - pos = self.labelPosition(value) - labelSize = lbl.textSize(painter.font()) - transform = self.labelTransformation(pos, labelSize) - painter.save() - painter.setWorldTransform(transform, True) - lbl.draw(painter, QRect(QPoint(0, 0), labelSize.toSize())) - painter.restore() - - def boundingLabelRect(self, font, value): - lbl = self.tickLabel(font, value) - if lbl.isEmpty(): - return QRect() - pos = self.labelPosition(value) - labelSize = lbl.textSize(font) - transform = self.labelTransformation(pos, labelSize) - return transform.mapRect(QRect(QPoint(0, 0), labelSize.toSize())) - - def labelTransformation(self, pos, size): - transform = QTransform() - transform.translate(pos.x(), pos.y()) - transform.rotate(self.labelRotation()) - - flags = self.labelAlignment() - if flags == 0: - flags = self.Flags[self.alignment()] - - if flags & Qt.AlignLeft: - x = -size.width() - elif flags & Qt.AlignRight: - x = 0. - else: - x = -(.5*size.width()) - - if flags & Qt.AlignTop: - y = -size.height() - elif flags & Qt.AlignBottom: - y = 0 - else: - y = -(.5*size.height()) - - transform.translate(x, y) - - return transform - - def labelRect(self, font, value): - lbl = self.tickLabel(font, value) - if not lbl or lbl.isEmpty(): - return QRectF(0., 0., 0., 0.) - pos = self.labelPosition(value) - labelSize = lbl.textSize(font) - transform = self.labelTransformation(pos, labelSize) - br = transform.mapRect(QRectF(QPointF(0, 0), labelSize)) - br.translate(-pos.x(), -pos.y()) - return br - - def labelSize(self, font, value): - return self.labelRect(font, value).size() - - def setLabelRotation(self, rotation): - self.__data.labelRotation = rotation - - def labelRotation(self): - return self.__data.labelRotation - - def setLabelAlignment(self, alignment): - self.__data.labelAlignment = alignment - - def labelAlignment(self): - return self.__data.labelAlignment - - def maxLabelWidth(self, font): - ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) - if not ticks: - return 0 - return np.ceil(max([self.labelSize(font, v).width() - for v in ticks if self.scaleDiv().contains(v)])) - - def maxLabelHeight(self, font): - ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) - if not ticks: - return 0 - return np.ceil(max([self.labelSize(font, v).height() - for v in ticks if self.scaleDiv().contains(v)])) - - def updateMap(self): - pos = self.__data.pos - len_ = self.__data.len - sm = self.scaleMap() - if self.orientation() == Qt.Vertical: - sm.setPaintInterval(pos.y()+len_, pos.y()) - else: - sm.setPaintInterval(pos.x(), pos.x()+len_) +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# Copyright (c) 2002 Uwe Rathmann, for the original C++ code +# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization +# (see LICENSE file for more details) + +""" +QwtAbstractScaleDraw +-------------------- + +.. autoclass:: QwtAbstractScaleDraw + :members: + +QwtScaleDraw +------------ + +.. autoclass:: QwtScaleDraw + :members: +""" + +import math +from datetime import datetime + +from qtpy.QtCore import ( + QLineF, + QPoint, + QPointF, + QRect, + QRectF, + Qt, + qFuzzyCompare, +) +from qtpy.QtGui import QFontMetrics, QPalette, QTransform + +from qwt._math import qwtRadians +from qwt.scale_div import QwtScaleDiv +from qwt.scale_map import QwtScaleMap +from qwt.text import QwtText + +# Plain-int aliases for Qt alignment flags. Qt6 exposes alignment flags as +# IntEnum members and bitwise operations on them go through Python's +# enum machinery (`__and__`/`__call__`), which is one of the dominant costs +# of label layout. Casting to int once and using these constants makes the +# bitwise tests in `labelTransformation` ~10x cheaper without changing +# semantics. +_ALIGN_LEFT = int(Qt.AlignLeft) +_ALIGN_RIGHT = int(Qt.AlignRight) +_ALIGN_TOP = int(Qt.AlignTop) +_ALIGN_BOTTOM = int(Qt.AlignBottom) + + +class QwtAbstractScaleDraw_PrivateData(object): + # See QwtText_PrivateData: ``QObject`` inheritance is unused and the + # base class' ``__init__`` is a measurable cost in tick-heavy renders. + __slots__ = ( + "spacing", + "penWidth", + "minExtent", + "components", + "tick_length", + "tick_lighter_factor", + "map", + "scaleDiv", + "labelCache", + ) + + def __init__(self): + self.spacing = 4 + self.penWidth = 0 + self.minExtent = 0.0 + + self.components = ( + QwtAbstractScaleDraw.Backbone + | QwtAbstractScaleDraw.Ticks + | QwtAbstractScaleDraw.Labels + ) + self.tick_length = { + QwtScaleDiv.MinorTick: 4.0, + QwtScaleDiv.MediumTick: 6.0, + QwtScaleDiv.MajorTick: 8.0, + } + self.tick_lighter_factor = { + QwtScaleDiv.MinorTick: 100, + QwtScaleDiv.MediumTick: 100, + QwtScaleDiv.MajorTick: 100, + } + + self.map = QwtScaleMap() + self.scaleDiv = QwtScaleDiv() + + self.labelCache = {} + + +class QwtAbstractScaleDraw(object): + """ + A abstract base class for drawing scales + + `QwtAbstractScaleDraw` can be used to draw linear or logarithmic scales. + + After a scale division has been specified as a `QwtScaleDiv` object + using `setScaleDiv()`, the scale can be drawn with the `draw()` member. + + Scale components: + + * `QwtAbstractScaleDraw.Backbone`: Backbone = the line where the ticks are located + * `QwtAbstractScaleDraw.Ticks`: Ticks + * `QwtAbstractScaleDraw.Labels`: Labels + + .. py:class:: QwtAbstractScaleDraw() + + The range of the scale is initialized to [0, 100], + The spacing (distance between ticks and labels) is + set to 4, the tick lengths are set to 4,6 and 8 pixels + """ + + # enum ScaleComponent + Backbone = 0x01 + Ticks = 0x02 + Labels = 0x04 + + def __init__(self): + self.__data = QwtAbstractScaleDraw_PrivateData() + + def extent(self, font): + """ + Calculate the extent + + The extent is the distance from the baseline to the outermost + pixel of the scale draw in opposite to its orientation. + It is at least minimumExtent() pixels. + + :param QFont font: Font used for drawing the tick labels + :return: Number of pixels + + .. seealso:: + + :py:meth:`setMinimumExtent()`, :py:meth:`minimumExtent()` + """ + return 0.0 + + def drawTick(self, painter, value, len_): + """ + Draw a tick + + :param QPainter painter: Painter + :param float value: Value of the tick + :param float len: Length of the tick + + .. seealso:: + + :py:meth:`drawBackbone()`, :py:meth:`drawLabel()` + """ + pass + + def drawBackbone(self, painter): + """ + Draws the baseline of the scale + + :param QPainter painter: Painter + + .. seealso:: + + :py:meth:`drawTick()`, :py:meth:`drawLabel()` + """ + pass + + def drawLabel(self, painter, value): + """ + Draws the label for a major scale tick + + :param QPainter painter: Painter + :param float value: Value + + .. seealso:: + + :py:meth:`drawTick()`, :py:meth:`drawBackbone()` + """ + pass + + def enableComponent(self, component, enable): + """ + En/Disable a component of the scale + + :param int component: Scale component + :param bool enable: On/Off + + .. seealso:: + + :py:meth:`hasComponent()` + """ + if enable: + self.__data.components |= component + else: + self.__data.components &= ~component + + def hasComponent(self, component): + """ + Check if a component is enabled + + :param int component: Component type + :return: True, when component is enabled + + .. seealso:: + + :py:meth:`enableComponent()` + """ + return self.__data.components & component + + def setScaleDiv(self, scaleDiv): + """ + Change the scale division + + :param qwt.scale_div.QwtScaleDiv scaleDiv: New scale division + """ + self.__data.scaleDiv = scaleDiv + self.__data.map.setScaleInterval(scaleDiv.lowerBound(), scaleDiv.upperBound()) + self.invalidateCache() + + def setTransformation(self, transformation): + """ + Change the transformation of the scale + + :param qwt.transform.QwtTransform transformation: New scale transformation + """ + self.__data.map.setTransformation(transformation) + + def scaleMap(self): + """ + :return: Map how to translate between scale and pixel values + """ + return self.__data.map + + def scaleDiv(self): + """ + :return: scale division + """ + return self.__data.scaleDiv + + def setPenWidth(self, width): + """ + Specify the width of the scale pen + + :param int width: Pen width + + .. seealso:: + + :py:meth:`penWidth()` + """ + if width < 0: + width = 0 + if width != self.__data.penWidth: + self.__data.penWidth = width + + def penWidth(self): + """ + :return: Scale pen width + + .. seealso:: + + :py:meth:`setPenWidth()` + """ + return self.__data.penWidth + + def draw(self, painter, palette): + """ + Draw the scale + + :param QPainter painter: The painter + :param QPalette palette: Palette, text color is used for the labels, + foreground color for ticks and backbone + """ + painter.save() + + pen = painter.pen() + pen.setWidth(self.__data.penWidth) + pen.setCosmetic(False) + painter.setPen(pen) + + if self.hasComponent(QwtAbstractScaleDraw.Labels): + painter.save() + painter.setPen(palette.color(QPalette.Text)) + majorTicks = self.__data.scaleDiv.ticks(QwtScaleDiv.MajorTick) + for v in majorTicks: + if self.__data.scaleDiv.contains(v): + self.drawLabel(painter, v) + painter.restore() + + if self.hasComponent(QwtAbstractScaleDraw.Ticks): + painter.save() + pen = painter.pen() + pen.setCapStyle(Qt.FlatCap) + default_color = palette.color(QPalette.WindowText) + for tickType in range(QwtScaleDiv.NTickTypes): + tickLen = self.__data.tick_length[tickType] + if tickLen <= 0.0: + continue + factor = self.__data.tick_lighter_factor[tickType] + pen.setColor(default_color.lighter(factor)) + painter.setPen(pen) + ticks = self.__data.scaleDiv.ticks(tickType) + for v in ticks: + if self.__data.scaleDiv.contains(v): + self.drawTick(painter, v, tickLen) + painter.restore() + + if self.hasComponent(QwtAbstractScaleDraw.Backbone): + painter.save() + pen = painter.pen() + pen.setColor(palette.color(QPalette.WindowText)) + pen.setCapStyle(Qt.FlatCap) + painter.setPen(pen) + self.drawBackbone(painter) + painter.restore() + + painter.restore() + + def setSpacing(self, spacing): + """ + Set the spacing between tick and labels + + The spacing is the distance between ticks and labels. + The default spacing is 4 pixels. + + :param float spacing: Spacing + + .. seealso:: + + :py:meth:`spacing()` + """ + if spacing < 0: + spacing = 0 + self.__data.spacing = spacing + + def spacing(self): + """ + Get the spacing + + The spacing is the distance between ticks and labels. + The default spacing is 4 pixels. + + :return: Spacing + + .. seealso:: + + :py:meth:`setSpacing()` + """ + return self.__data.spacing + + def setMinimumExtent(self, minExtent): + """ + Set a minimum for the extent + + The extent is calculated from the components of the + scale draw. In situations, where the labels are + changing and the layout depends on the extent (f.e scrolling + a scale), setting an upper limit as minimum extent will + avoid jumps of the layout. + + :param float minExtent: Minimum extent + + .. seealso:: + + :py:meth:`extent()`, :py:meth:`minimumExtent()` + """ + if minExtent < 0.0: + minExtent = 0.0 + self.__data.minExtent = minExtent + + def minimumExtent(self): + """ + Get the minimum extent + + :return: Minimum extent + + .. seealso:: + + :py:meth:`extent()`, :py:meth:`setMinimumExtent()` + """ + return self.__data.minExtent + + def setTickLength(self, tick_type, length): + """ + Set the length of the ticks + + :param int tick_type: Tick type + :param float length: New length + + .. warning:: + + the length is limited to [0..1000] + """ + if tick_type not in self.__data.tick_length: + raise ValueError("Invalid tick type: %r" % tick_type) + self.__data.tick_length[tick_type] = min([1000.0, max([0.0, length])]) + + def tickLength(self, tick_type): + """ + :param int tick_type: Tick type + :return: Length of the ticks + + .. seealso:: + + :py:meth:`setTickLength()`, :py:meth:`maxTickLength()` + """ + if tick_type not in self.__data.tick_length: + raise ValueError("Invalid tick type: %r" % tick_type) + return self.__data.tick_length[tick_type] + + def maxTickLength(self): + """ + :return: Length of the longest tick + + Useful for layout calculations + + .. seealso:: + + :py:meth:`tickLength()`, :py:meth:`setTickLength()` + """ + return max([0.0] + list(self.__data.tick_length.values())) + + def setTickLighterFactor(self, tick_type, factor): + """ + Set the color lighter factor of the ticks + + :param int tick_type: Tick type + :param int factor: New factor + """ + if tick_type not in self.__data.tick_length: + raise ValueError("Invalid tick type: %r" % tick_type) + self.__data.tick_lighter_factor[tick_type] = min([0, factor]) + + def tickLighterFactor(self, tick_type): + """ + :param int tick_type: Tick type + :return: Color lighter factor of the ticks + + .. seealso:: + + :py:meth:`setTickLighterFactor()` + """ + if tick_type not in self.__data.tick_length: + raise ValueError("Invalid tick type: %r" % tick_type) + return self.__data.tick_lighter_factor[tick_type] + + def label(self, value): + """ + Convert a value into its representing label + + The value is converted to a plain text using + `QLocale().toString(value)`. + This method is often overloaded by applications to have individual + labels. + + :param float value: Value + :return: Label string + """ + # Adding a space before the value is a way to add a margin on the left + # of the scale. This helps to avoid truncating the first digit of the + # tick labels while keeping a tight layout. + return " %g" % value + + def tickLabel(self, font, value): + """ + Convert a value into its representing label and cache it. + + The conversion between value and label is called very often + in the layout and painting code. Unfortunately the + calculation of the label sizes might be slow (really slow + for rich text in Qt4), so it's necessary to cache the labels. + + :param QFont font: Font + :param float value: Value + :return: Tuple (tick label, text size) + """ + lbl = self.__data.labelCache.get(value) + if lbl is None: + lbl = QwtText(self.label(value)) + lbl.setRenderFlags(0) + lbl.setLayoutAttribute(QwtText.MinimumLayout) + self.__data.labelCache[value] = lbl + return lbl, lbl.textSize(font) + + def invalidateCache(self): + """ + Invalidate the cache used by `tickLabel()` + + The cache is invalidated, when a new `QwtScaleDiv` is set. If + the labels need to be changed. while the same `QwtScaleDiv` is set, + `invalidateCache()` needs to be called manually. + """ + self.__data.labelCache.clear() + + +class QwtScaleDraw_PrivateData(object): + # See QwtText_PrivateData: ``QObject`` inheritance is unused and the + # base class' ``__init__`` is a measurable cost in tick-heavy renders. + __slots__ = ( + "len", + "alignment", + "orientation", + "labelAlignment", + "labelRotation", + "labelAutoSize", + "pos", + ) + + def __init__(self): + self.len = 0 + self.alignment = QwtScaleDraw.BottomScale + # Cached orientation - kept in sync by ``QwtScaleDraw.setAlignment`` + # so that the very hot ``orientation()`` accessor avoids any test. + self.orientation = Qt.Horizontal + self.labelAlignment = 0 + self.labelRotation = 0.0 + self.labelAutoSize = True + self.pos = QPointF() + + +class QwtScaleDraw(QwtAbstractScaleDraw): + """ + A class for drawing scales + + QwtScaleDraw can be used to draw linear or logarithmic scales. + A scale has a position, an alignment and a length, which can be specified . + The labels can be rotated and aligned + to the ticks using `setLabelRotation()` and `setLabelAlignment()`. + + After a scale division has been specified as a QwtScaleDiv object + using `QwtAbstractScaleDraw.setScaleDiv(scaleDiv)`, + the scale can be drawn with the `QwtAbstractScaleDraw.draw()` member. + + Alignment of the scale draw: + + * `QwtScaleDraw.BottomScale`: The scale is below + * `QwtScaleDraw.TopScale`: The scale is above + * `QwtScaleDraw.LeftScale`: The scale is left + * `QwtScaleDraw.RightScale`: The scale is right + + .. py:class:: QwtScaleDraw() + + The range of the scale is initialized to [0, 100], + The position is at (0, 0) with a length of 100. + The orientation is `QwtAbstractScaleDraw.Bottom`. + """ + + # enum Alignment + BottomScale, TopScale, LeftScale, RightScale = list(range(4)) + Flags = ( + Qt.AlignHCenter | Qt.AlignBottom, # BottomScale + Qt.AlignHCenter | Qt.AlignTop, # TopScale + Qt.AlignLeft | Qt.AlignVCenter, # LeftScale + Qt.AlignRight | Qt.AlignVCenter, # RightScale + ) + + def __init__(self): + QwtAbstractScaleDraw.__init__(self) + self.__data = QwtScaleDraw_PrivateData() + self.setLength(100) + self._max_label_sizes = {} + + def alignment(self): + """ + :return: Alignment of the scale + + .. seealso:: + + :py:meth:`setAlignment()` + """ + return self.__data.alignment + + def setAlignment(self, align): + """ + Set the alignment of the scale + + :param int align: Alignment of the scale + + Alignment of the scale draw: + + * `QwtScaleDraw.BottomScale`: The scale is below + * `QwtScaleDraw.TopScale`: The scale is above + * `QwtScaleDraw.LeftScale`: The scale is left + * `QwtScaleDraw.RightScale`: The scale is right + + The default alignment is `QwtScaleDraw.BottomScale` + + .. seealso:: + + :py:meth:`alignment()` + """ + self.__data.alignment = align + # Keep cached orientation in sync (see ``orientation()``). + if align == self.BottomScale or align == self.TopScale: + self.__data.orientation = Qt.Horizontal + else: + self.__data.orientation = Qt.Vertical + + def orientation(self): + """ + Return the orientation + + TopScale, BottomScale are horizontal (`Qt.Horizontal`) scales, + LeftScale, RightScale are vertical (`Qt.Vertical`) scales. + + :return: Orientation of the scale + + .. seealso:: + + :py:meth:`alignment()` + """ + # Pre-computed by ``setAlignment`` - this method is called per tick. + return self.__data.orientation + + def getBorderDistHint(self, font): + """ + Determine the minimum border distance + + This member function returns the minimum space + needed to draw the mark labels at the scale's endpoints. + + :param QFont font: Font + :return: tuple `(start, end)` + + Returned tuple: + + * start: Start border distance + * end: End border distance + """ + start, end = 0, 1.0 + + if not self.hasComponent(QwtAbstractScaleDraw.Labels): + return start, end + + ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) + if len(ticks) == 0: + return start, end + + scale_map = self.scaleMap() + transform = scale_map.transform + minTick = ticks[0] + minPos = transform(minTick) + maxTick = minTick + maxPos = minPos + + for tick in ticks: + tickPos = transform(tick) + if tickPos < minPos: + minTick = tick + minPos = tickPos + if tickPos > maxPos: + maxTick = tick + maxPos = tickPos + + s = 0.0 + e = 0.0 + if self.orientation() == Qt.Vertical: + s = -self.labelRect(font, minTick).top() + s -= abs(minPos - round(scale_map.p2())) + + e = self.labelRect(font, maxTick).bottom() + e -= abs(maxPos - scale_map.p1()) + else: + s = -self.labelRect(font, minTick).left() + s -= abs(minPos - scale_map.p1()) + + e = self.labelRect(font, maxTick).right() + e -= abs(maxPos - scale_map.p2()) + + return max(math.ceil(s), 0), max(math.ceil(e), 0) + + def minLabelDist(self, font): + """ + Determine the minimum distance between two labels, that is necessary + that the texts don't overlap. + + :param QFont font: Font + :return: The maximum width of a label + + .. seealso:: + + :py:meth:`getBorderDistHint()` + """ + if not self.hasComponent(QwtAbstractScaleDraw.Labels): + return 0 + + ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) + if not ticks: + return 0 + + fm = QFontMetrics(font) + vertical = self.orientation() == Qt.Vertical + + bRect1 = QRectF() + bRect2 = self.labelRect(font, ticks[0]) + if vertical: + bRect2.setRect(-bRect2.bottom(), 0.0, bRect2.height(), bRect2.width()) + + maxDist = 0.0 + + for tick in ticks: + bRect1 = bRect2 + bRect2 = self.labelRect(font, tick) + if vertical: + bRect2.setRect(-bRect2.bottom(), 0.0, bRect2.height(), bRect2.width()) + + dist = fm.leading() + if bRect1.right() > 0: + dist += bRect1.right() + if bRect2.left() < 0: + dist += -bRect2.left() + + if dist > maxDist: + maxDist = dist + + angle = qwtRadians(self.labelRotation()) + if vertical: + angle += math.pi / 2 + + sinA = math.sin(angle) + if qFuzzyCompare(sinA + 1.0, 1.0): + return math.ceil(maxDist) + + fmHeight = fm.ascent() - 2 + + labelDist = fmHeight / math.sin(angle) * math.cos(angle) + if labelDist < 0: + labelDist = -labelDist + + if labelDist > maxDist: + labelDist = maxDist + + if labelDist < fmHeight: + labelDist = fmHeight + + return math.ceil(labelDist) + + def extent(self, font): + """ + Calculate the width/height that is needed for a + vertical/horizontal scale. + + The extent is calculated from the pen width of the backbone, + the major tick length, the spacing and the maximum width/height + of the labels. + + :param QFont font: Font used for painting the labels + :return: Extent + + .. seealso:: + + :py:meth:`minLength()` + """ + d = 0.0 + if self.hasComponent(QwtAbstractScaleDraw.Labels): + if self.orientation() == Qt.Vertical: + d = self.maxLabelWidth(font) + else: + d = self.maxLabelHeight(font) + if d > 0: + d += self.spacing() + if self.hasComponent(QwtAbstractScaleDraw.Ticks): + d += self.maxTickLength() + if self.hasComponent(QwtAbstractScaleDraw.Backbone): + pw = max([1, self.penWidth()]) + d += pw + return max([d, self.minimumExtent()]) + + def minLength(self, font): + """ + Calculate the minimum length that is needed to draw the scale + + :param QFont font: Font used for painting the labels + :return: Minimum length that is needed to draw the scale + + .. seealso:: + + :py:meth:`extent()` + """ + startDist, endDist = self.getBorderDistHint(font) + sd = self.scaleDiv() + minorCount = len(sd.ticks(QwtScaleDiv.MinorTick)) + len( + sd.ticks(QwtScaleDiv.MediumTick) + ) + majorCount = len(sd.ticks(QwtScaleDiv.MajorTick)) + lengthForLabels = 0 + if self.hasComponent(QwtAbstractScaleDraw.Labels): + lengthForLabels = self.minLabelDist(font) * majorCount + lengthForTicks = 0 + if self.hasComponent(QwtAbstractScaleDraw.Ticks): + pw = max([1, self.penWidth()]) + lengthForTicks = math.ceil((majorCount + minorCount) * (pw + 1.0)) + return startDist + endDist + max([lengthForLabels, lengthForTicks]) + + def labelPosition(self, value): + """ + Find the position, where to paint a label + + The position has a distance that depends on the length of the ticks + in direction of the `alignment()`. + + :param float value: Value + :return: Position, where to paint a label + """ + tval = self.scaleMap().transform(value) + dist = self.spacing() + hasComponent = self.hasComponent + if hasComponent(QwtAbstractScaleDraw.Backbone): + dist += max(1, self.penWidth()) + if hasComponent(QwtAbstractScaleDraw.Ticks): + dist += self.tickLength(QwtScaleDiv.MajorTick) + + alignment = self.alignment() + pos = self.__data.pos + if alignment == self.RightScale: + return QPointF(pos.x() + dist, tval) + if alignment == self.LeftScale: + return QPointF(pos.x() - dist, tval) + if alignment == self.BottomScale: + return QPointF(tval, pos.y() + dist) + # TopScale + return QPointF(tval, pos.y() - dist) + + def drawTick(self, painter, value, len_): + """ + Draw a tick + + :param QPainter painter: Painter + :param float value: Value of the tick + :param float len: Length of the tick + + .. seealso:: + + :py:meth:`drawBackbone()`, :py:meth:`drawLabel()` + """ + if len_ <= 0: + return + pos = self.__data.pos + tval = self.scaleMap().transform(value) + pw = self.penWidth() + a = 0 + if self.alignment() == self.LeftScale: + x1 = pos.x() + a + x2 = pos.x() + a - pw - len_ + painter.drawLine(QLineF(x1, tval, x2, tval)) + elif self.alignment() == self.RightScale: + x1 = pos.x() + x2 = pos.x() + pw + len_ + painter.drawLine(QLineF(x1, tval, x2, tval)) + elif self.alignment() == self.BottomScale: + y1 = pos.y() + y2 = pos.y() + pw + len_ + painter.drawLine(QLineF(tval, y1, tval, y2)) + elif self.alignment() == self.TopScale: + y1 = pos.y() + a + y2 = pos.y() - pw - len_ + a + painter.drawLine(QLineF(tval, y1, tval, y2)) + + def drawBackbone(self, painter): + """ + Draws the baseline of the scale + + :param QPainter painter: Painter + + .. seealso:: + + :py:meth:`drawTick()`, :py:meth:`drawLabel()` + """ + pos = self.__data.pos + len_ = self.__data.len + off = 0.5 * self.penWidth() + if self.alignment() == self.LeftScale: + x = pos.x() - off + painter.drawLine(QLineF(x, pos.y(), x, pos.y() + len_)) + elif self.alignment() == self.RightScale: + x = pos.x() + off + painter.drawLine(QLineF(x, pos.y(), x, pos.y() + len_)) + elif self.alignment() == self.TopScale: + y = pos.y() - off + painter.drawLine(QLineF(pos.x(), y, pos.x() + len_, y)) + elif self.alignment() == self.BottomScale: + y = pos.y() + off + painter.drawLine(QLineF(pos.x(), y, pos.x() + len_, y)) + + def move(self, *args): + """ + Move the position of the scale + + The meaning of the parameter pos depends on the alignment: + + * `QwtScaleDraw.LeftScale`: + + The origin is the topmost point of the backbone. The backbone is a + vertical line. Scale marks and labels are drawn at the left of the + backbone. + + * `QwtScaleDraw.RightScale`: + + The origin is the topmost point of the backbone. The backbone is a + vertical line. Scale marks and labels are drawn at the right of + the backbone. + + * `QwtScaleDraw.TopScale`: + + The origin is the leftmost point of the backbone. The backbone is + a horizontal line. Scale marks and labels are drawn above the + backbone. + + * `QwtScaleDraw.BottomScale`: + + The origin is the leftmost point of the backbone. The backbone is + a horizontal line Scale marks and labels are drawn below the + backbone. + + .. py:method:: move(x, y) + :noindex: + + :param float x: X coordinate + :param float y: Y coordinate + + .. py:method:: move(pos) + :noindex: + + :param QPointF pos: position + + .. seealso:: + + :py:meth:`pos()`, :py:meth:`setLength()` + """ + if len(args) == 2: + x, y = args + self.move(QPointF(x, y)) + elif len(args) == 1: + (pos,) = args + self.__data.pos = pos + self.updateMap() + else: + raise TypeError( + "%s().move() takes 1 or 2 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + + def pos(self): + """ + :return: Origin of the scale + + .. seealso:: + + :py:meth:`pos()`, :py:meth:`setLength()` + """ + return self.__data.pos + + def setLength(self, length): + """ + Set the length of the backbone. + + The length doesn't include the space needed for overlapping labels. + + :param float length: Length of the backbone + + .. seealso:: + + :py:meth:`move()`, :py:meth:`minLabelDist()` + """ + if length >= 0 and length < 10: + length = 10 + if length < 0 and length > -10: + length = -10 + self.__data.len = length + self.updateMap() + + def length(self): + """ + :return: the length of the backbone + + .. seealso:: + + :py:meth:`setLength()`, :py:meth:`pos()` + """ + return self.__data.len + + def drawLabel(self, painter, value): + """ + Draws the label for a major scale tick + + :param QPainter painter: Painter + :param float value: Value + + .. seealso:: + + :py:meth:`drawTick()`, :py:meth:`drawBackbone()`, + :py:meth:`boundingLabelRect()` + """ + lbl, labelSize = self.tickLabel(painter.font(), value) + if lbl is None or lbl.isEmpty(): + return + pos = self.labelPosition(value) + transform = self.labelTransformation(pos, labelSize) + painter.save() + painter.setWorldTransform(transform, True) + lbl.draw(painter, QRect(QPoint(0, 0), labelSize.toSize())) + painter.restore() + + def boundingLabelRect(self, font, value): + """ + Find the bounding rectangle for the label. + + The coordinates of the rectangle are absolute (calculated from + `pos()`) in direction of the tick. + + :param QFont font: Font used for painting + :param float value: Value + :return: Bounding rectangle + + .. seealso:: + + :py:meth:`labelRect()` + """ + lbl, labelSize = self.tickLabel(font, value) + if lbl.isEmpty(): + return QRect() + pos = self.labelPosition(value) + transform = self.labelTransformation(pos, labelSize) + return transform.mapRect(QRect(QPoint(0, 0), labelSize.toSize())) + + def labelTransformation(self, pos, size): + """ + Calculate the transformation that is needed to paint a label + depending on its alignment and rotation. + + :param QPointF pos: Position where to paint the label + :param QSizeF size: Size of the label + :return: Transformation matrix + + .. seealso:: + + :py:meth:`setLabelAlignment()`, :py:meth:`setLabelRotation()` + """ + transform = QTransform() + transform.translate(pos.x(), pos.y()) + transform.rotate(self.labelRotation()) + + flags = self.labelAlignment() + if flags == 0: + flags = self.Flags[self.alignment()] + # Cast to plain int once to avoid the per-bit Qt6 enum overhead. + flags = int(flags) + + if flags & _ALIGN_LEFT: + x = -size.width() + elif flags & _ALIGN_RIGHT: + x = 0.0 + else: + x = -(0.5 * size.width()) + + if flags & _ALIGN_TOP: + y = -size.height() + elif flags & _ALIGN_BOTTOM: + y = 0 + else: + y = -(0.5 * size.height()) + + transform.translate(x, y) + + return transform + + def labelRect(self, font, value): + """ + Find the bounding rectangle for the label. The coordinates of + the rectangle are relative to spacing + tick length from the backbone + in direction of the tick. + + :param QFont font: Font used for painting + :param float value: Value + :return: Bounding rectangle that is needed to draw a label + """ + lbl, labelSize = self.tickLabel(font, value) + if not lbl or lbl.isEmpty(): + return QRectF(0.0, 0.0, 0.0, 0.0) + # Fast path: when the label is not rotated, the contribution of + # ``pos`` cancels out (transform.translate(pos) followed by + # br.translate(-pos)). This avoids ``labelPosition``, + # ``labelTransformation`` and ``QTransform.mapRect`` entirely - all + # of which are dominant costs in tick-heavy layouts. + if self.labelRotation() == 0.0: + flags = self.labelAlignment() + if flags == 0: + flags = self.Flags[self.alignment()] + flags = int(flags) + w = labelSize.width() + h = labelSize.height() + if flags & _ALIGN_LEFT: + x = -w + elif flags & _ALIGN_RIGHT: + x = 0.0 + else: + x = -0.5 * w + if flags & _ALIGN_TOP: + y = -h + elif flags & _ALIGN_BOTTOM: + y = 0.0 + else: + y = -0.5 * h + return QRectF(x, y, w, h) + pos = self.labelPosition(value) + transform = self.labelTransformation(pos, labelSize) + br = transform.mapRect(QRectF(QPointF(0, 0), labelSize)) + br.translate(-pos.x(), -pos.y()) + return br + + def labelSize(self, font, value): + """ + Calculate the size that is needed to draw a label + + :param QFont font: Label font + :param float value: Value + :return: Size that is needed to draw a label + """ + return self.labelRect(font, value).size() + + def setLabelRotation(self, rotation): + """ + Rotate all labels. + + When changing the rotation, it might be necessary to + adjust the label flags too. Finding a useful combination is + often the result of try and error. + + :param float rotation: Angle in degrees. When changing the label rotation, the + label flags often needs to be adjusted too. + + .. seealso:: + + :py:meth:`setLabelAlignment()`, :py:meth:`labelRotation()`, + :py:meth:`labelAlignment()` + """ + self.__data.labelRotation = rotation + + def labelRotation(self): + """ + :return: the label rotation + + .. seealso:: + + :py:meth:`setLabelRotation()`, :py:meth:`labelAlignment()` + """ + return self.__data.labelRotation + + def setLabelAlignment(self, alignment): + """ + Change the label flags + + Labels are aligned to the point tick length + spacing away from the + backbone. + + The alignment is relative to the orientation of the label text. + In case of an flags of 0 the label will be aligned + depending on the orientation of the scale: + + * `QwtScaleDraw.TopScale`: `Qt.AlignHCenter | Qt.AlignTop` + * `QwtScaleDraw.BottomScale`: `Qt.AlignHCenter | Qt.AlignBottom` + * `QwtScaleDraw.LeftScale`: `Qt.AlignLeft | Qt.AlignVCenter` + * `QwtScaleDraw.RightScale`: `Qt.AlignRight | Qt.AlignVCenter` + + Changing the alignment is often necessary for rotated labels. + + :param Qt.Alignment alignment Or'd `Qt.AlignmentFlags` + + .. seealso:: + + :py:meth:`setLabelRotation()`, :py:meth:`labelRotation()`, + :py:meth:`labelAlignment()` + + .. warning:: + + The various alignments might be confusing. The alignment of the + label is not the alignment of the scale and is not the alignment + of the flags (`QwtText.flags()`) returned from + `QwtAbstractScaleDraw.label()`. + """ + self.__data.labelAlignment = alignment + + def labelAlignment(self): + """ + :return: the label flags + + .. seealso:: + + :py:meth:`setLabelAlignment()`, :py:meth:`labelRotation()` + """ + return self.__data.labelAlignment + + def setLabelAutoSize(self, state): + """ + Set label automatic size option state + + When drawing text labels, if automatic size mode is enabled (default + behavior), the axes are drawn in order to optimize layout space and + depends on text label individual sizes. Otherwise, width and height + won't change when axis range is changing. + + This option is not implemented in Qwt C++ library: this may be used + either as an optimization (updating plot layout is faster when this + option is enabled) or as an appearance preference (with Qwt default + behavior, the size of axes may change when zooming and/or panning + plot canvas which in some cases may not be desired). + + :param bool state: On/off + + .. seealso:: + + :py:meth:`labelAutoSize()` + """ + self.__data.labelAutoSize = state + + def labelAutoSize(self): + """ + :return: True if automatic size option is enabled for labels + + .. seealso:: + + :py:meth:`setLabelAutoSize()` + """ + return self.__data.labelAutoSize + + def _get_max_label_size(self, font): + key = (font.toString(), self.labelRotation()) + size = self._max_label_sizes.get(key) + if size is None: + size = self.labelSize(font, -999999) # -999999 is the biggest label + size.setWidth(math.ceil(size.width())) + size.setHeight(math.ceil(size.height())) + return self._max_label_sizes.setdefault(key, size) + else: + return size + + def maxLabelWidth(self, font): + """ + :param QFont font: Font + :return: the maximum width of a label + """ + ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) + if not ticks: + return 0 + if self.labelAutoSize(): + vmax = sorted( + [v for v in ticks if self.scaleDiv().contains(v)], + key=lambda obj: len("%g" % obj), + )[-1] + return math.ceil(self.labelSize(font, vmax).width()) + ## Original implementation (closer to Qwt's C++ code, but slower): + # return math.ceil(max([self.labelSize(font, v).width() + # for v in ticks if self.scaleDiv().contains(v)])) + else: + return self._get_max_label_size(font).width() + + def maxLabelHeight(self, font): + """ + :param QFont font: Font + :return: the maximum height of a label + """ + ticks = self.scaleDiv().ticks(QwtScaleDiv.MajorTick) + if not ticks: + return 0 + if self.labelAutoSize(): + vmax = sorted( + [v for v in ticks if self.scaleDiv().contains(v)], + key=lambda obj: len("%g" % obj), + )[-1] + return math.ceil(self.labelSize(font, vmax).height()) + ## Original implementation (closer to Qwt's C++ code, but slower): + # return math.ceil(max([self.labelSize(font, v).height() + # for v in ticks if self.scaleDiv().contains(v)])) + else: + return self._get_max_label_size(font).height() + + def updateMap(self): + pos = self.__data.pos + len_ = self.__data.len + sm = self.scaleMap() + if self.orientation() == Qt.Vertical: + sm.setPaintInterval(pos.y() + len_, pos.y()) + else: + sm.setPaintInterval(pos.x(), pos.x() + len_) + + +class QwtDateTimeScaleDraw(QwtScaleDraw): + """Scale draw for datetime axis + + This class formats axis labels as date/time strings from Unix timestamps. + + Args: + format: Format string for datetime display (default: "%Y-%m-%d %H:%M:%S"). + Uses Python datetime.strftime() format codes. + spacing: Spacing between labels (default: 4) + + Examples: + >>> # Create a datetime scale with default format + >>> scale = QwtDateTimeScaleDraw() + + >>> # Create a datetime scale with custom format (time only) + >>> scale = QwtDateTimeScaleDraw(format="%H:%M:%S") + + >>> # Create a datetime scale with date only + >>> scale = QwtDateTimeScaleDraw(format="%Y-%m-%d", spacing=4) + """ + + def __init__(self, format: str = "%Y-%m-%d %H:%M:%S", spacing: int = 4) -> None: + super().__init__() + self._format = format + self.setSpacing(spacing) + + def get_format(self) -> str: + """Get the current datetime format string + + Returns: + str: Format string + """ + return self._format + + def set_format(self, format: str) -> None: + """Set the datetime format string + + Args: + format: Format string for datetime display + """ + self._format = format + + def label(self, value: float) -> QwtText: + """Convert a timestamp value to a formatted date/time label + + Args: + value: Unix timestamp (seconds since epoch) + + Returns: + QwtText: Formatted label + """ + try: + dt = datetime.fromtimestamp(value) + return QwtText(dt.strftime(self._format)) + except (ValueError, OSError): + # Handle invalid timestamps + return QwtText("") diff --git a/qwt/scale_engine.py b/qwt/scale_engine.py index 7eeb326..8b6545f 100644 --- a/qwt/scale_engine.py +++ b/qwt/scale_engine.py @@ -5,201 +5,514 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from __future__ import division +""" +QwtScaleEngine +-------------- -from qwt.interval import QwtInterval -from qwt.scale_div import QwtScaleDiv -from qwt.transform import QwtLogTransform -from qwt.math import qwtFuzzyCompare -from qwt.transform import QwtTransform +.. autoclass:: QwtScaleEngine + :members: + +QwtLinearScaleEngine +-------------------- + +.. autoclass:: QwtLinearScaleEngine + :members: -from qwt.qt.QtCore import qFuzzyCompare +QwtLogScaleEngine +----------------- + +.. autoclass:: QwtLogScaleEngine + :members: +""" + +import math +import sys import numpy as np +from qtpy.QtCore import qFuzzyCompare -DBL_MAX = np.finfo(float).max -LOG_MIN = 1.0e-100 -LOG_MAX = 1.0e100 +from qwt._math import qwtFuzzyCompare +from qwt.interval import QwtInterval +from qwt.scale_div import QwtScaleDiv +from qwt.transform import QwtLogTransform, QwtTransform +DBL_MAX = sys.float_info.max +LOG_MIN = 1.0e-150 +LOG_MAX = 1.0e150 -def qwtLog(base, value): - return np.log(value)/np.log(base) def qwtLogInterval(base, interval): - return QwtInterval(qwtLog(base, interval.minValue()), - qwtLog(base, interval.maxValue())) + return QwtInterval( + math.log(interval.minValue(), base), math.log(interval.maxValue(), base) + ) + def qwtPowInterval(base, interval): - return QwtInterval(np.power(base, interval.minValue()), - np.power(base, interval.maxValue())) + return QwtInterval( + math.pow(base, interval.minValue()), math.pow(base, interval.maxValue()) + ) + def qwtStepSize(intervalSize, maxSteps, base): + """this version often doesn't find the best ticks: f.e for 15: 5, 10""" minStep = divideInterval(intervalSize, maxSteps, base) - if minStep != 0.: - numTicks = np.ceil(abs(intervalSize/minStep))-1 - if qwtFuzzyCompare((numTicks+1)*abs(minStep), - abs(intervalSize), intervalSize) > 0: - return .5*intervalSize + if minStep != 0.0: + # # ticks per interval + numTicks = math.ceil(abs(intervalSize / minStep)) - 1 + # Do the minor steps fit into the interval? + if ( + qwtFuzzyCompare( + (numTicks + 1) * abs(minStep), abs(intervalSize), intervalSize + ) + > 0 + ): + # The minor steps doesn't fit into the interval + return 0.5 * intervalSize return minStep + EPS = 1.0e-6 + def ceilEps(value, intervalSize): - eps = EPS*intervalSize - value = (value-eps)/intervalSize - return np.ceil(value)*intervalSize + """ + Ceil a value, relative to an interval + + :param float value: Value to be ceiled + :param float intervalSize: Interval size + :return: Rounded value + + .. seealso:: + + :py:func:`qwt.scale_engine.floorEps()` + """ + eps = EPS * intervalSize + value = (value - eps) / intervalSize + return math.ceil(value) * intervalSize + def floorEps(value, intervalSize): - eps = EPS*intervalSize - value = (value+eps)/intervalSize - return np.floor(value)*intervalSize + """ + Floor a value, relative to an interval + + :param float value: Value to be floored + :param float intervalSize: Interval size + :return: Rounded value + + .. seealso:: + + :py:func:`qwt.scale_engine.ceilEps()` + """ + eps = EPS * intervalSize + value = (value + eps) / intervalSize + return math.floor(value) * intervalSize + def divideEps(intervalSize, numSteps): - if numSteps == 0. or intervalSize == 0.: - return 0. - return (intervalSize-(EPS*intervalSize))/numSteps + """ + Divide an interval into steps + + `stepSize = (intervalSize - intervalSize * 10**-6) / numSteps` + + :param float intervalSize: Interval size + :param float numSteps: Number of steps + :return: Step size + """ + if numSteps == 0.0 or intervalSize == 0.0: + return 0.0 + return (intervalSize - (EPS * intervalSize)) / numSteps + def divideInterval(intervalSize, numSteps, base): + """ + Calculate a step size for a given interval + + :param float intervalSize: Interval size + :param float numSteps: Number of steps + :param int base: Base for the division (usually 10) + :return: Calculated step size + """ if numSteps <= 0: - return 0. + return 0.0 v = divideEps(intervalSize, numSteps) - if v == 0.: - return 0. + if v == 0.0: + return 0.0 - lx = qwtLog(base, abs(v)) - p = np.floor(lx) - fraction = np.power(base, lx-p) + lx = math.log(abs(v), base) + p = math.floor(lx) + fraction = math.pow(base, lx - p) n = base - while n > 1 and fraction <= n/2: - n /= 2 - - stepSize = n*np.power(base, p) + while n > 1 and fraction <= n // 2: + n //= 2 + + stepSize = n * math.pow(base, p) if v < 0: stepSize = -stepSize - + return stepSize class QwtScaleEngine_PrivateData(object): def __init__(self): self.attributes = QwtScaleEngine.NoAttribute - self.lowerMargin = 0. - self.upperMargin = 0. - self.referenceValue = 0. + self.lowerMargin = 0.0 + self.upperMargin = 0.0 + self.referenceValue = 0.0 self.base = 10 self.transform = None # QwtTransform class QwtScaleEngine(object): - + """ + Base class for scale engines. + + A scale engine tries to find "reasonable" ranges and step sizes + for scales. + + The layout of the scale can be varied with `setAttribute()`. + + `PythonQwt` offers implementations for logarithmic and linear scales. + + Layout attributes: + + * `QwtScaleEngine.NoAttribute`: No attributes + * `QwtScaleEngine.IncludeReference`: Build a scale which includes the + `reference()` value + * `QwtScaleEngine.Symmetric`: Build a scale which is symmetric to the + `reference()` value + * `QwtScaleEngine.Floating`: The endpoints of the scale are supposed to + be equal the outmost included values plus the specified margins (see + `setMargins()`). If this attribute is *not* set, the endpoints of the + scale will be integer multiples of the step size. + * `QwtScaleEngine.Inverted`: Turn the scale upside down + """ + # enum Attribute NoAttribute = 0x00 IncludeReference = 0x01 Symmetric = 0x02 Floating = 0x04 Inverted = 0x08 - + def __init__(self, base=10): self.__data = QwtScaleEngine_PrivateData() self.setBase(base) - + + def autoScale(self, maxNumSteps, x1, x2, stepSize, relativeMargin=0.0): + """ + Align and divide an interval + + :param int maxNumSteps: Max. number of steps + :param float x1: First limit of the interval (In/Out) + :param float x2: Second limit of the interval (In/Out) + :param float stepSize: Step size + :param float relativeMargin: Margin as a fraction of the interval width + :return: tuple (x1, x2, stepSize) + """ + pass + + def divideScale(self, x1, x2, maxMajorSteps, maxMinorSteps, stepSize=0.0): + """ + Calculate a scale division + + :param float x1: First interval limit + :param float x2: Second interval limit + :param int maxMajorSteps: Maximum for the number of major steps + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Step size. If stepSize == 0.0, the scaleEngine calculates one + :return: Calculated scale division + """ + pass + def setTransformation(self, transform): + """ + Assign a transformation + + :param qwt.transform.QwtTransform transform: Transformation + + The transformation object is used as factory for clones + that are returned by `transformation()` + + The scale engine takes ownership of the transformation. + + .. seealso:: + + :py:meth:`QwtTransform.copy()`, :py:meth:`transformation()` + """ assert transform is None or isinstance(transform, QwtTransform) if transform != self.__data.transform: self.__data.transform = transform - + def transformation(self): + """ + Create and return a clone of the transformation + of the engine. When the engine has no special transformation + None is returned, indicating no transformation. + + :return: A clone of the transfomation + + .. seealso:: + + :py:meth:`setTransformation()` + """ if self.__data.transform: return self.__data.transform.copy() - + def lowerMargin(self): + """ + :return: the margin at the lower end of the scale + + The default margin is 0. + + .. seealso:: + + :py:meth:`setMargins()` + """ return self.__data.lowerMargin - + def upperMargin(self): + """ + :return: the margin at the upper end of the scale + + The default margin is 0. + + .. seealso:: + + :py:meth:`setMargins()` + """ return self.__data.upperMargin - + def setMargins(self, lower, upper): - self.__data.lowerMargin = max([lower, 0.]) - self.__data.upperMargin = max([upper, 0.]) - + """ + Specify margins at the scale's endpoints + + :param float lower: minimum distance between the scale's lower boundary and the smallest enclosed value + :param float upper: minimum distance between the scale's upper boundary and the greatest enclosed value + :return: A clone of the transfomation + + Margins can be used to leave a minimum amount of space between + the enclosed intervals and the boundaries of the scale. + + .. warning:: + + `QwtLogScaleEngine` measures the margins in decades. + + .. seealso:: + + :py:meth:`upperMargin()`, :py:meth:`lowerMargin()` + """ + self.__data.lowerMargin = max([lower, 0.0]) + self.__data.upperMargin = max([upper, 0.0]) + def divideInterval(self, intervalSize, numSteps): + """ + Calculate a step size for a given interval + + :param float intervalSize: Interval size + :param float numSteps: Number of steps + :return: Step size + """ return divideInterval(intervalSize, numSteps, self.__data.base) - + def contains(self, interval, value): + """ + Check if an interval "contains" a value + + :param float intervalSize: Interval size + :param float value: Value + :return: True, when the value is inside the interval + """ if not interval.isValid(): return False - elif qwtFuzzyCompare(value, interval.minValue(), interval.width()) < 0: - return False - elif qwtFuzzyCompare(value, interval.maxValue(), interval.width()) > 0: - return False - else: - return True - + min_v = interval.minValue() + max_v = interval.maxValue() + eps = abs(1.0e-6 * (max_v - min_v)) + return not (min_v - value > eps or value - max_v > eps) + def strip(self, ticks, interval): + """ + Remove ticks from a list, that are not inside an interval + + :param list ticks: Tick list + :param qwt.interval.QwtInterval interval: Interval + :return: Stripped tick list + """ if not interval.isValid() or not ticks: return [] - if self.contains(interval, ticks[0]) and\ - self.contains(interval, ticks[-1]): + # Inline ``contains`` to avoid one Python call per tick: ``strip`` is + # called by buildTicks for every layout pass and is one of the + # dominant costs in tick-heavy plots. + min_v = interval.minValue() + max_v = interval.maxValue() + eps = abs(1.0e-6 * (max_v - min_v)) + lo = min_v - eps + hi = max_v + eps + if lo <= ticks[0] and ticks[-1] <= hi: return ticks - return [tick for tick in ticks - if self.contains(interval, tick)] - + return [tick for tick in ticks if lo <= tick <= hi] + def buildInterval(self, value): - if value == 0.: - delta = .5 + """ + Build an interval around a value + + In case of v == 0.0 the interval is [-0.5, 0.5], + otherwide it is [0.5 * v, 1.5 * v] + + :param float value: Initial value + :return: Calculated interval + """ + if value == 0.0: + delta = 0.5 else: - delta = abs(.5*value) - if DBL_MAX-delta < value: - return QwtInterval(DBL_MAX-delta, DBL_MAX) - if -DBL_MAX+delta > value: - return QwtInterval(-DBL_MAX, -DBL_MAX+delta) - return QwtInterval(value-delta, value+delta) - + delta = abs(0.5 * value) + if DBL_MAX - delta < value: + return QwtInterval(DBL_MAX - delta, DBL_MAX) + if -DBL_MAX + delta > value: + return QwtInterval(-DBL_MAX, -DBL_MAX + delta) + return QwtInterval(value - delta, value + delta) + def setAttribute(self, attribute, on=True): + """ + Change a scale attribute + + :param int attribute: Attribute to change + :param bool on: On/Off + :return: Calculated interval + + .. seealso:: + + :py:meth:`testAttribute()` + """ if on: self.__data.attributes |= attribute else: self.__data.attributes &= ~attribute - + def testAttribute(self, attribute): + """ + :param int attribute: Attribute to be tested + :return: True, if attribute is enabled + + .. seealso:: + + :py:meth:`setAttribute()` + """ return self.__data.attributes & attribute - + def setAttributes(self, attributes): + """ + Change the scale attribute + + :param attributes: Set scale attributes + + .. seealso:: + + :py:meth:`attributes()` + """ self.__data.attributes = attributes - + def attributes(self): + """ + :return: Scale attributes + + .. seealso:: + + :py:meth:`setAttributes()`, :py:meth:`testAttribute()` + """ return self.__data.attributes - + def setReference(self, r): + """ + Specify a reference point + + :param float r: new reference value + + The reference point is needed if options `IncludeReference` or + `Symmetric` are active. Its default value is 0.0. + """ self.__data.referenceValue = r - + def reference(self): + """ + :return: the reference value + + .. seealso:: + + :py:meth:`setReference()`, :py:meth:`setAttribute()` + """ return self.__data.referenceValue - + def setBase(self, base): + """ + Set the base of the scale engine + + While a base of 10 is what 99.9% of all applications need + certain scales might need a different base: f.e 2 + + The default setting is 10 + + :param int base: Base of the engine + + .. seealso:: + + :py:meth:`base()` + """ self.__data.base = max([base, 2]) - + def base(self): + """ + :return: Base of the scale engine + + .. seealso:: + + :py:meth:`setBase()` + """ return self.__data.base class QwtLinearScaleEngine(QwtScaleEngine): + r""" + A scale engine for linear scales + + The step size will fit into the pattern + \f$\left\{ 1,2,5\right\} \cdot 10^{n}\f$, where n is an integer. + """ + def __init__(self, base=10): super(QwtLinearScaleEngine, self).__init__(base) - - def autoScale(self, maxNumSteps, x1, x2, stepSize): + + def autoScale(self, maxNumSteps, x1, x2, stepSize, relativeMargin=0.0): + """ + Align and divide an interval + + :param int maxNumSteps: Max. number of steps + :param float x1: First limit of the interval (In/Out) + :param float x2: Second limit of the interval (In/Out) + :param float stepSize: Step size + :param float relativeMargin: Margin as a fraction of the interval width + :return: tuple (x1, x2, stepSize) + + .. seealso:: + + :py:meth:`setAttribute()` + """ + # Apply the relative margin (fraction of the interval width) in linear space: + if relativeMargin > 0.0: + margin = (x2 - x1) * relativeMargin + x1 -= margin + x2 += margin + interval = QwtInterval(x1, x2) interval = interval.normalized() - interval.setMinValue(interval.minValue()-self.lowerMargin()) - interval.setMaxValue(interval.maxValue()+self.upperMargin()) + interval.setMinValue(interval.minValue() - self.lowerMargin()) + interval.setMaxValue(interval.maxValue() + self.upperMargin()) if self.testAttribute(QwtScaleEngine.Symmetric): interval = interval.symmetrize(self.reference()) if self.testAttribute(QwtScaleEngine.IncludeReference): interval = interval.extend(self.reference()) - if interval.width() == 0.: + if interval.width() == 0.0: interval = self.buildInterval(interval.minValue()) - stepSize = divideInterval(interval.width(), - max([maxNumSteps, 1]), self.base()) + stepSize = divideInterval(interval.width(), max([maxNumSteps, 1]), self.base()) if not self.testAttribute(QwtScaleEngine.Floating): interval = self.align(interval, stepSize) x1 = interval.minValue() @@ -208,261 +521,368 @@ def autoScale(self, maxNumSteps, x1, x2, stepSize): x1, x2 = x2, x1 stepSize = -stepSize return x1, x2, stepSize - - def divideScale(self, x1, x2, maxMajorSteps, maxMinorSteps, stepSize=0.): + + def divideScale(self, x1, x2, maxMajorSteps, maxMinorSteps, stepSize=0.0): + """ + Calculate a scale division for an interval + + :param float x1: First interval limit + :param float x2: Second interval limit + :param int maxMajorSteps: Maximum for the number of major steps + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Step size. If stepSize == 0.0, the scaleEngine calculates one + :return: Calculated scale division + """ interval = QwtInterval(x1, x2).normalized() if interval.width() <= 0: return QwtScaleDiv() stepSize = abs(stepSize) - if stepSize == 0.: + if stepSize == 0.0: if maxMajorSteps < 1: maxMajorSteps = 1 - stepSize = divideInterval(interval.width(), maxMajorSteps, - self.base()) + stepSize = divideInterval(interval.width(), maxMajorSteps, self.base()) scaleDiv = QwtScaleDiv() - if stepSize != 0.: + if stepSize != 0.0: ticks = self.buildTicks(interval, stepSize, maxMinorSteps) scaleDiv = QwtScaleDiv(interval, ticks) if x1 > x2: scaleDiv.invert() return scaleDiv - + def buildTicks(self, interval, stepSize, maxMinorSteps): + """ + Calculate ticks for an interval + + :param qwt.interval.QwtInterval interval: Interval + :param float stepSize: Step size + :param int maxMinorSteps: Maximum number of minor steps + :return: Calculated ticks + """ ticks = [[] for _i in range(QwtScaleDiv.NTickTypes)] boundingInterval = self.align(interval, stepSize) - ticks[QwtScaleDiv.MajorTick] = self.buildMajorTicks(boundingInterval, - stepSize) + ticks[QwtScaleDiv.MajorTick] = self.buildMajorTicks(boundingInterval, stepSize) if maxMinorSteps > 0: self.buildMinorTicks(ticks, maxMinorSteps, stepSize) for i in range(QwtScaleDiv.NTickTypes): ticks[i] = self.strip(ticks[i], interval) for j in range(len(ticks[i])): - if qwtFuzzyCompare(ticks[i][j], 0., stepSize) == 0: - ticks[i][j] = 0. + if qwtFuzzyCompare(ticks[i][j], 0.0, stepSize) == 0: + ticks[i][j] = 0.0 return ticks - + def buildMajorTicks(self, interval, stepSize): - numTicks = min([round(interval.width()/stepSize)+1, 10000]) + """ + Calculate major ticks for an interval + + :param qwt.interval.QwtInterval interval: Interval + :param float stepSize: Step size + :return: Calculated ticks + """ + numTicks = min([round(interval.width() / stepSize) + 1, 10000]) + if np.isnan(numTicks): + numTicks = 0 ticks = [interval.minValue()] - for i in range(1, int(numTicks-1)): - ticks += [interval.minValue()+i*stepSize] + for i in range(1, int(numTicks - 1)): + ticks += [interval.minValue() + i * stepSize] ticks += [interval.maxValue()] return ticks - + def buildMinorTicks(self, ticks, maxMinorSteps, stepSize): + """ + Calculate minor ticks for an interval + + :param list ticks: Major ticks (returned) + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Step size + """ minStep = qwtStepSize(stepSize, maxMinorSteps, self.base()) - if minStep == 0.: + if minStep == 0.0: return - numTicks = int(np.ceil(abs(stepSize/minStep))-1) + numTicks = int(math.ceil(abs(stepSize / minStep)) - 1) medIndex = -1 if numTicks % 2: - medIndex = numTicks/2 + medIndex = numTicks // 2 for val in ticks[QwtScaleDiv.MajorTick]: for k in range(numTicks): val += minStep alignedValue = val - if qwtFuzzyCompare(val, 0., stepSize) == 0: - alignedValue = 0. + if qwtFuzzyCompare(val, 0.0, stepSize) == 0: + alignedValue = 0.0 if k == medIndex: ticks[QwtScaleDiv.MediumTick] += [alignedValue] else: ticks[QwtScaleDiv.MinorTick] += [alignedValue] - + def align(self, interval, stepSize): + """ + Align an interval to a step size + + The limits of an interval are aligned that both are integer + multiples of the step size. + + :param qwt.interval.QwtInterval interval: Interval + :param float stepSize: Step size + :return: Aligned interval + """ x1 = interval.minValue() x2 = interval.maxValue() - if -DBL_MAX+stepSize <= x1: + eps = 0.000000000001 + if -DBL_MAX + stepSize <= x1: x = floorEps(x1, stepSize) - if qwtFuzzyCompare(x1, x, stepSize) != 0: + if abs(x) <= eps or not qFuzzyCompare(x1, x): x1 = x - if DBL_MAX-stepSize >= x2: + if DBL_MAX - stepSize >= x2: x = ceilEps(x2, stepSize) - if qwtFuzzyCompare(x2, x, stepSize) != 0: + if abs(x) <= eps or not qFuzzyCompare(x2, x): x2 = x return QwtInterval(x1, x2) class QwtLogScaleEngine(QwtScaleEngine): + """ + A scale engine for logarithmic scales + + The step size is measured in *decades* and the major step size will be + adjusted to fit the pattern {1,2,3,5}.10**n, where n is a natural number + including zero. + + .. warning:: + + The step size as well as the margins are measured in *decades*. + """ + def __init__(self, base=10): super(QwtLogScaleEngine, self).__init__(base) self.setTransformation(QwtLogTransform()) - - def autoScale(self, maxNumSteps, x1, x2, stepSize): + + def autoScale(self, maxNumSteps, x1, x2, stepSize, relativeMargin=0.0): + """ + Align and divide an interval + + :param int maxNumSteps: Max. number of steps + :param float x1: First limit of the interval (In/Out) + :param float x2: Second limit of the interval (In/Out) + :param float stepSize: Step size + :param float relativeMargin: Margin as a fraction of the interval width + :return: tuple (x1, x2, stepSize) + + .. seealso:: + + :py:meth:`setAttribute()` + """ if x1 > x2: x1, x2 = x2, x1 logBase = self.base() - interval = QwtInterval(x1/np.power(logBase, self.lowerMargin()), - x2*np.power(logBase, self.upperMargin())) - if interval.maxValue()/interval.minValue() < logBase: + + # Apply the relative margin (fraction of the interval width) in logarithmic + # space, and convert back to linear space. + if relativeMargin is not None: + x1 = min(max([x1, LOG_MIN]), LOG_MAX) + x2 = min(max([x2, LOG_MIN]), LOG_MAX) + log_margin = math.log(x2 / x1, logBase) * relativeMargin + x1 /= math.pow(logBase, log_margin) + x2 *= math.pow(logBase, log_margin) + + interval = QwtInterval( + x1 / math.pow(logBase, self.lowerMargin()), + x2 * math.pow(logBase, self.upperMargin()), + ) + if interval.maxValue() / interval.minValue() < logBase: linearScaler = QwtLinearScaleEngine() linearScaler.setAttributes(self.attributes()) linearScaler.setReference(self.reference()) linearScaler.setMargins(self.lowerMargin(), self.upperMargin()) - - x1, x2, stepSize = linearScaler.autoScale(maxNumSteps, - x1, x2, stepSize) - + + x1, x2, stepSize = linearScaler.autoScale(maxNumSteps, x1, x2, stepSize) + linearInterval = QwtInterval(x1, x2).normalized() linearInterval = linearInterval.limited(LOG_MIN, LOG_MAX) - - if linearInterval.maxValue()/linearInterval.minValue() < logBase: - if stepSize < 0.: - stepSize = -qwtLog(logBase, abs(stepSize)) - else: - stepSize = qwtLog(logBase, stepSize) + + if linearInterval.maxValue() / linearInterval.minValue() < logBase: + # The min / max interval is too short to be represented as a log scale. + # Set the step to 0, so that a new step is calculated and a linear scale is used. + stepSize = 0.0 return x1, x2, stepSize - - logRef = 1. - if self.reference() > LOG_MIN/2: - logRef = min([self.reference(), LOG_MAX/2]) - + + logRef = 1.0 + if self.reference() > LOG_MIN / 2: + logRef = min([self.reference(), LOG_MAX / 2]) + if self.testAttribute(QwtScaleEngine.Symmetric): - delta = max([interval.maxValue()/logRef, - logRef/interval.minValue()]) - interval.setInterval(logRef/delta, logRef*delta) - + delta = max([interval.maxValue() / logRef, logRef / interval.minValue()]) + interval.setInterval(logRef / delta, logRef * delta) + if self.testAttribute(QwtScaleEngine.IncludeReference): interval = interval.extend(logRef) - interval = interval.limited(LOG_MIN, LOG_MAX) - - if interval.width() == 0.: + interval = interval.limited(LOG_MIN, LOG_MAX) + + if interval.width() == 0.0: interval = self.buildInterval(interval.minValue()) - + stepSize = self.divideInterval( - qwtLogInterval(logBase, interval).width(), max([maxNumSteps, 1])) - if stepSize < 1.: - stepSize = 1. - + qwtLogInterval(logBase, interval).width(), max([maxNumSteps, 1]) + ) + if stepSize < 1.0: + stepSize = 1.0 + if not self.testAttribute(QwtScaleEngine.Floating): interval = self.align(interval, stepSize) - + x1 = interval.minValue() x2 = interval.maxValue() - + if self.testAttribute(QwtScaleEngine.Inverted): x1, x2 = x2, x1 stepSize = -stepSize - - def divideScale(self, x1, x2, maxMajorSteps, maxMinorSteps, stepSize=0.): + + return x1, x2, stepSize + + def divideScale(self, x1, x2, maxMajorSteps, maxMinorSteps, stepSize=0.0): + """ + Calculate a scale division for an interval + + :param float x1: First interval limit + :param float x2: Second interval limit + :param int maxMajorSteps: Maximum for the number of major steps + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Step size. If stepSize == 0.0, the scaleEngine calculates one + :return: Calculated scale division + """ interval = QwtInterval(x1, x2).normalized() interval = interval.limited(LOG_MIN, LOG_MAX) - + if interval.width() <= 0: return QwtScaleDiv() - + logBase = self.base() - - if interval.maxValue()/interval.minValue() < logBase: + + if interval.maxValue() / interval.minValue() < logBase: linearScaler = QwtLinearScaleEngine() linearScaler.setAttributes(self.attributes()) linearScaler.setReference(self.reference()) linearScaler.setMargins(self.lowerMargin(), self.upperMargin()) - - if stepSize != 0.: - if stepSize < 0.: - stepSize = -np.power(logBase, -stepSize) - else: - stepSize = np.power(logBase, stepSize) - - return linearScaler.divideScale(x1, x2, maxMajorSteps, - maxMinorSteps, stepSize) - + return linearScaler.divideScale( + x1, x2, maxMajorSteps, maxMinorSteps, stepSize + ) + stepSize = abs(stepSize) - if stepSize == 0.: + if stepSize == 0.0: if maxMajorSteps < 1: maxMajorSteps = 1 stepSize = self.divideInterval( - qwtLogInterval(logBase, interval).width(), maxMajorSteps) - if stepSize < 1.: - stepSize = 1. - + qwtLogInterval(logBase, interval).width(), maxMajorSteps + ) + if stepSize < 1.0: + stepSize = 1.0 + scaleDiv = QwtScaleDiv() - if stepSize != 0.: + if stepSize != 0.0: ticks = self.buildTicks(interval, stepSize, maxMinorSteps) scaleDiv = QwtScaleDiv(interval, ticks) - + if x1 > x2: scaleDiv.invert() - + return scaleDiv - + def buildTicks(self, interval, stepSize, maxMinorSteps): + """ + Calculate ticks for an interval + + :param qwt.interval.QwtInterval interval: Interval + :param float stepSize: Step size + :param int maxMinorSteps: Maximum number of minor steps + :return: Calculated ticks + """ ticks = [[] for _i in range(QwtScaleDiv.NTickTypes)] boundingInterval = self.align(interval, stepSize) - ticks[QwtScaleDiv.MajorTick] = self.buildMajorTicks(boundingInterval, - stepSize) + ticks[QwtScaleDiv.MajorTick] = self.buildMajorTicks(boundingInterval, stepSize) if maxMinorSteps > 0: self.buildMinorTicks(ticks, maxMinorSteps, stepSize) for i in range(QwtScaleDiv.NTickTypes): ticks[i] = self.strip(ticks[i], interval) return ticks - + def buildMajorTicks(self, interval, stepSize): + """ + Calculate major ticks for an interval + + :param qwt.interval.QwtInterval interval: Interval + :param float stepSize: Step size + :return: Calculated ticks + """ width = qwtLogInterval(self.base(), interval).width() - numTicks = min([int(round(width/stepSize))+1, 10000]) + numTicks = min([int(round(width / stepSize)) + 1, 10000]) - lxmin = np.log(interval.minValue()) - lxmax = np.log(interval.maxValue()) - lstep = (lxmax-lxmin)/float(numTicks-1) + lxmin = math.log(interval.minValue()) + lxmax = math.log(interval.maxValue()) + lstep = (lxmax - lxmin) / float(numTicks - 1) ticks = [interval.minValue()] - for i in range(1, numTicks-1): - ticks += [np.exp(lxmin+float(i)*lstep)] + for i in range(1, numTicks - 1): + ticks += [math.exp(lxmin + float(i) * lstep)] ticks += [interval.maxValue()] return ticks def buildMinorTicks(self, ticks, maxMinorSteps, stepSize): + """ + Calculate minor ticks for an interval + + :param list ticks: Major ticks (returned) + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Step size + """ logBase = self.base() - + if stepSize < 1.1: - minStep = self.divideInterval(stepSize, maxMinorSteps+1) - if minStep == 0.: + minStep = self.divideInterval(stepSize, maxMinorSteps + 1) + if minStep == 0.0: return - - numSteps = int(round(stepSize/minStep)) - + + numSteps = int(round(stepSize / minStep)) + mediumTickIndex = -1 if numSteps > 2 and numSteps % 2 == 0: - mediumTickIndex = numSteps/2 - + mediumTickIndex = numSteps // 2 + for v in ticks[QwtScaleDiv.MajorTick]: - s = logBase/numSteps - if s >= 1.: - if not qFuzzyCompare(s, 1.): - ticks[QwtScaleDiv.MinorTick] += [v*s] + s = logBase / numSteps + if s >= 1.0: + if not qFuzzyCompare(s, 1.0): + ticks[QwtScaleDiv.MinorTick] += [v * s] for j in range(2, numSteps): - ticks[QwtScaleDiv.MinorTick] += [v*j*s] + ticks[QwtScaleDiv.MinorTick] += [v * j * s] else: for j in range(1, numSteps): - tick = v + j*v*(logBase-1)/numSteps + tick = v + j * v * (logBase - 1) / numSteps if j == mediumTickIndex: ticks[QwtScaleDiv.MediumTick] += [tick] else: ticks[QwtScaleDiv.MinorTick] += [tick] - + else: minStep = self.divideInterval(stepSize, maxMinorSteps) - if minStep == 0.: + if minStep == 0.0: return - - if minStep < 1.: - minStep = 1. - - numTicks = int(round(stepSize/minStep))-1 - - if qwtFuzzyCompare((numTicks+1)*minStep, stepSize, stepSize) > 0: + + if minStep < 1.0: + minStep = 1.0 + + numTicks = int(round(stepSize / minStep)) - 1 + + if qwtFuzzyCompare((numTicks + 1) * minStep, stepSize, stepSize) > 0: numTicks = 0 - + if numTicks < 1: return - + mediumTickIndex = -1 if numTicks > 2 and numTicks % 2: - mediumTickIndex = numTicks/2 - - minFactor = max([np.power(logBase, minStep), float(logBase)]) - + mediumTickIndex = numTicks // 2 + + minFactor = max([math.pow(logBase, minStep), float(logBase)]) + for tick in ticks[QwtScaleDiv.MajorTick]: for j in range(numTicks): tick *= minFactor @@ -472,16 +892,196 @@ def buildMinorTicks(self, ticks, maxMinorSteps, stepSize): ticks[QwtScaleDiv.MinorTick] += [tick] def align(self, interval, stepSize): + """ + Align an interval to a step size + + The limits of an interval are aligned that both are integer + multiples of the step size. + + :param qwt.interval.QwtInterval interval: Interval + :param float stepSize: Step size + :return: Aligned interval + """ intv = qwtLogInterval(self.base(), interval) - + x1 = floorEps(intv.minValue(), stepSize) if qwtFuzzyCompare(interval.minValue(), x1, stepSize) == 0: x1 = interval.minValue() - + x2 = ceilEps(intv.maxValue(), stepSize) if qwtFuzzyCompare(interval.maxValue(), x2, stepSize) == 0: x2 = interval.maxValue() - + return qwtPowInterval(self.base(), QwtInterval(x1, x2)) - - \ No newline at end of file + + +class QwtDateTimeScaleEngine(QwtLinearScaleEngine): + """ + A scale engine for datetime scales that creates intelligent time-based tick intervals. + + This engine calculates tick intervals that correspond to meaningful time units + (seconds, minutes, hours, days, weeks, months, years) rather than arbitrary + numerical spacing. + """ + + # Time intervals in seconds + TIME_INTERVALS = [ + 1, # 1 second + 5, # 5 seconds + 10, # 10 seconds + 15, # 15 seconds + 30, # 30 seconds + 60, # 1 minute + 2 * 60, # 2 minutes + 5 * 60, # 5 minutes + 10 * 60, # 10 minutes + 15 * 60, # 15 minutes + 30 * 60, # 30 minutes + 60 * 60, # 1 hour + 2 * 60 * 60, # 2 hours + 3 * 60 * 60, # 3 hours + 6 * 60 * 60, # 6 hours + 12 * 60 * 60, # 12 hours + 24 * 60 * 60, # 1 day + 2 * 24 * 60 * 60, # 2 days + 7 * 24 * 60 * 60, # 1 week + 2 * 7 * 24 * 60 * 60, # 2 weeks + 30 * 24 * 60 * 60, # 1 month (approx) + 3 * 30 * 24 * 60 * 60, # 3 months (approx) + 6 * 30 * 24 * 60 * 60, # 6 months (approx) + 365 * 24 * 60 * 60, # 1 year (approx) + ] + + def __init__(self, base=10): + super(QwtDateTimeScaleEngine, self).__init__(base) + + def divideScale(self, x1, x2, maxMajorSteps, maxMinorSteps, stepSize=0.0): + """ + Calculate a scale division for a datetime interval + + :param float x1: First interval limit (Unix timestamp) + :param float x2: Second interval limit (Unix timestamp) + :param int maxMajorSteps: Maximum for the number of major steps + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Step size. If stepSize == 0.0, calculates intelligent datetime step + :return: Calculated scale division + """ + interval = QwtInterval(x1, x2).normalized() + if interval.width() <= 0: + return QwtScaleDiv() + + # If stepSize is provided and > 0, use parent implementation + if stepSize > 0.0: + return super(QwtDateTimeScaleEngine, self).divideScale( + x1, x2, maxMajorSteps, maxMinorSteps, stepSize + ) + + # Calculate intelligent datetime step size + duration = interval.width() # Duration in seconds + + # Find the best time interval for the given duration and max steps + best_step = self._find_best_time_step(duration, maxMajorSteps) + + # Use the calculated datetime step + scaleDiv = QwtScaleDiv() + if best_step > 0.0: + ticks = self.buildTicks(interval, best_step, maxMinorSteps) + scaleDiv = QwtScaleDiv(interval, ticks) + + if x1 > x2: + scaleDiv.invert() + + return scaleDiv + + def _find_best_time_step(self, duration, max_steps): + """ + Find the best time interval step for the given duration and maximum steps. + + :param float duration: Total duration in seconds + :param int max_steps: Maximum number of major ticks + :return: Best step size in seconds + """ + if max_steps < 1: + max_steps = 1 + + # Calculate the target step size + target_step = duration / max_steps + + # Find the time interval that is closest to our target + best_step = self.TIME_INTERVALS[0] + min_error = abs(target_step - best_step) + + for interval in self.TIME_INTERVALS: + error = abs(target_step - interval) + if error < min_error: + min_error = error + best_step = interval + # If the interval is getting much larger than target, stop + elif interval > target_step * 2: + break + + return float(best_step) + + def buildMinorTicks(self, ticks, maxMinorSteps, stepSize): + """ + Calculate minor ticks for datetime intervals + + :param list ticks: List of tick arrays + :param int maxMinorSteps: Maximum number of minor steps + :param float stepSize: Major tick step size + """ + if maxMinorSteps < 1: + return + + # For datetime, create intelligent minor tick intervals + minor_step = self._get_minor_step(stepSize, maxMinorSteps) + + if minor_step <= 0: + return + + major_ticks = ticks[QwtScaleDiv.MajorTick] + if len(major_ticks) < 2: + return + + minor_ticks = [] + + # Generate minor ticks between each pair of major ticks + for i in range(len(major_ticks) - 1): + start = major_ticks[i] + end = major_ticks[i + 1] + + # Add minor ticks between start and end + current = start + minor_step + while current < end: + minor_ticks.append(current) + current += minor_step + + ticks[QwtScaleDiv.MinorTick] = minor_ticks + + def _get_minor_step(self, major_step, max_minor_steps): + """ + Calculate appropriate minor tick step size for datetime intervals + + :param float major_step: Major tick step size in seconds + :param int max_minor_steps: Maximum number of minor steps + :return: Minor tick step size in seconds + """ + # Define sensible minor tick divisions for different time scales + if major_step >= 365 * 24 * 60 * 60: # 1 year or more + return 30 * 24 * 60 * 60 # 1 month + elif major_step >= 30 * 24 * 60 * 60: # 1 month or more + return 7 * 24 * 60 * 60 # 1 week + elif major_step >= 7 * 24 * 60 * 60: # 1 week or more + return 24 * 60 * 60 # 1 day + elif major_step >= 24 * 60 * 60: # 1 day or more + return 6 * 60 * 60 # 6 hours + elif major_step >= 60 * 60: # 1 hour or more + return 15 * 60 # 15 minutes + elif major_step >= 10 * 60: # 10 minutes or more + return 2 * 60 # 2 minutes + elif major_step >= 60: # 1 minute or more + return 15 # 15 seconds + elif major_step >= 10: # 10 seconds or more + return 2 # 2 seconds + else: # Less than 10 seconds + return major_step / max(max_minor_steps, 2) diff --git a/qwt/scale_map.py b/qwt/scale_map.py index 4a3b11d..b99fe65 100644 --- a/qwt/scale_map.py +++ b/qwt/scale_map.py @@ -5,33 +5,69 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) -from qwt.math import qwtFuzzyCompare +""" +QwtScaleMap +----------- -from qwt.qt.QtCore import QRectF, QPointF +.. autoclass:: QwtScaleMap + :members: +""" + +from qtpy.QtCore import QPointF, QRectF + +from qwt._math import qwtFuzzyCompare class QwtScaleMap(object): + """ + A scale map + + `QwtScaleMap` offers transformations from the coordinate system + of a scale into the linear coordinate system of a paint device + and vice versa. + + The scale and paint device intervals are both set to [0,1]. + + .. py:class:: QwtScaleMap([other=None]) + + Constructor (eventually, copy constructor) + + :param qwt.scale_map.QwtScaleMap other: Other scale map + + .. py:class:: QwtScaleMap(p1, p2, s1, s2) + :noindex: + + Constructor (was provided by `PyQwt` but not by `Qwt`) + + :param int p1: First border of the paint interval + :param int p2: Second border of the paint interval + :param float s1: First border of the scale interval + :param float s2: Second border of the scale interval + """ + def __init__(self, *args): - self.__transform = None # QwtTransform - self.__s1 = 0. - self.__s2 = 1. - self.__p1 = 0. - self.__p2 = 1. + self.__transform = None # QwtTransform + self.__s1 = 0.0 + self.__s2 = 1.0 + self.__p1 = 0.0 + self.__p2 = 1.0 other = None if len(args) == 1: - other, = args + (other,) = args elif len(args) == 4: - s1, s2, p1, p2 = args + p1, p2, s1, s2 = args self.__s1 = s1 self.__s2 = s2 self.__p1 = p1 self.__p2 = p2 elif len(args) != 0: - raise TypeError("%s() takes 1, 3, or 4 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s() takes 0, 1, or 4 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) if other is None: - self.__cnv = 1. - self.__ts1 = 0. + self.__cnv = 1.0 + self.__ts1 = 0.0 else: self.__s1 = other.__s1 self.__s2 = other.__s2 @@ -43,54 +79,120 @@ def __init__(self, *args): self.__transform = other.__transform.copy() def __eq__(self, other): - return self.__s1 == other.__s1 and\ - self.__s2 == other.__s2 and\ - self.__p1 == other.__p1 and\ - self.__p2 == other.__p2 and\ - self.__cnv == other.__cnv and\ - self.__ts1 == other.__ts1 + return ( + self.__s1 == other.__s1 + and self.__s2 == other.__s2 + and self.__p1 == other.__p1 + and self.__p2 == other.__p2 + and self.__cnv == other.__cnv + and self.__ts1 == other.__ts1 + ) def s1(self): + """ + :return: First border of the scale interval + """ return self.__s1 - + def s2(self): + """ + :return: Second border of the scale interval + """ return self.__s2 - + def p1(self): + """ + :return: First border of the paint interval + """ return self.__p1 - + def p2(self): + """ + :return: Second border of the paint interval + """ return self.__p2 - + def pDist(self): + """ + :return: `abs(p2() - p1())` + """ return abs(self.__p2 - self.__p1) - + def sDist(self): + """ + :return: `abs(s2() - s1())` + """ return abs(self.__s2 - self.__s1) def transform_scalar(self, s): + """ + Transform a point related to the scale interval into an point + related to the interval of the paint device + + :param float s: Value relative to the coordinates of the scale + :return: Transformed value + + .. seealso:: + + :py:meth:`invTransform_scalar()` + """ if self.__transform: s = self.__transform.transform(s) - return self.__p1 + (s - self.__ts1)*self.__cnv - + return self.__p1 + (s - self.__ts1) * self.__cnv + def invTransform_scalar(self, p): - s = self.__ts1 + ( p - self.__p1 ) / self.__cnv + """ + Transform an paint device value into a value in the + interval of the scale. + + :param float p: Value relative to the coordinates of the paint device + :return: Transformed value + + .. seealso:: + + :py:meth:`transform_scalar()` + """ + if self.__cnv == 0: + s = self.__ts1 # avoid divide by zero + else: + s = self.__ts1 + (p - self.__p1) / self.__cnv if self.__transform: s = self.__transform.invTransform(s) return s - + def isInverting(self): - return ( self.__p1 < self.__p2 ) != ( self.__s1 < self.__s2 ) - + """ + :return: True, when ( p1() < p2() ) != ( s1() < s2() ) + """ + return (self.__p1 < self.__p2) != (self.__s1 < self.__s2) + def setTransformation(self, transform): + """ + Initialize the map with a transformation + + :param qwt.transform.QwtTransform transform: Transformation + """ if self.__transform != transform: self.__transform = transform self.setScaleInterval(self.__s1, self.__s2) - + def transformation(self): + """ + :return: the transformation + """ return self.__transform - + def setScaleInterval(self, s1, s2): + """ + Specify the borders of the scale interval + + :param float s1: first border + :param float s2: second border + + .. warning:: + + Scales might be aligned to transformation depending boundaries + """ self.__s1 = s1 self.__s2 = s2 if self.__transform: @@ -99,35 +201,66 @@ def setScaleInterval(self, s1, s2): self.updateFactor() def setPaintInterval(self, p1, p2): + """ + Specify the borders of the paint device interval + + :param float p1: first border + :param float p2: second border + """ self.__p1 = p1 self.__p2 = p2 self.updateFactor() - + def updateFactor(self): self.__ts1 = self.__s1 ts2 = self.__s2 if self.__transform: self.__ts1 = self.__transform.transform(self.__ts1) ts2 = self.__transform.transform(ts2) - self.__cnv = 1. - if self.__ts1 != ts2: - self.__cnv = (self.__p2 - self.__p1)/(ts2 - self.__ts1) - + if self.__ts1 == ts2: + # Degenerate scale: collapse every value to ``p1`` (matches the + # symmetric guard in ``invTransform_scalar`` and the C++ Qwt + # behaviour). + self.__cnv = 0.0 + else: + self.__cnv = (self.__p2 - self.__p1) / (ts2 - self.__ts1) + def transform(self, *args): - """Transform from scale to paint coordinates - - Scalar: scalemap.transform(scalar) - Point (QPointF): scalemap.transform(xMap, yMap, pos) - Rectangle (QRectF): scalemap.transform(xMap, yMap, rect) """ - if len(args) == 1: - # Scalar transform - return self.transform_scalar(args[0]) - elif len(args) == 3 and isinstance(args[2], QPointF): + Transform a rectangle from scale to paint coordinates. + + Transfom a scalar: + + :param float scalar: Scalar + + Transfom a rectangle: + + :param qwt.scale_map.QwtScaleMap xMap: X map + :param qwt.scale_map.QwtScaleMap yMap: Y map + :param QRectF rect: Rectangle in paint coordinates + + Transfom a point: + + :param qwt.scale_map.QwtScaleMap xMap: X map + :param qwt.scale_map.QwtScaleMap yMap: Y map + :param QPointF pos: Position in scale coordinates + + .. seealso:: + + :py:meth:`invTransform()` + """ + nargs = len(args) + if nargs == 1: + # Scalar transform: inline the fast path for the dominant case + # (avoids one Python call frame per tick label). + s = args[0] + if self.__transform: + s = self.__transform.transform(s) + return self.__p1 + (s - self.__ts1) * self.__cnv + elif nargs == 3 and isinstance(args[2], QPointF): xMap, yMap, pos = args - return QPointF(xMap.transform(pos.x()), - yMap.transform(pos.y())) - elif len(args) == 3 and isinstance(args[2], QRectF): + return QPointF(xMap.transform(pos.x()), yMap.transform(pos.y())) + elif nargs == 3 and isinstance(args[2], QRectF): xMap, yMap, rect = args x1 = xMap.transform(rect.left()) x2 = xMap.transform(rect.right()) @@ -137,22 +270,24 @@ def transform(self, *args): x1, x2 = x2, x1 if y2 < y1: y1, y2 = y2, y1 - if qwtFuzzyCompare(x1, 0., x2-x1) == 0: - x1 = 0. - if qwtFuzzyCompare(x2, 0., x2-x1) == 0: - x2 = 0. - if qwtFuzzyCompare(y1, 0., y2-y1) == 0: - y1 = 0. - if qwtFuzzyCompare(y2, 0., y2-y1) == 0: - y2 = 0. - return QRectF(x1, y1, x2-x1+1, y2-y1+1) + if qwtFuzzyCompare(x1, 0.0, x2 - x1) == 0: + x1 = 0.0 + if qwtFuzzyCompare(x2, 0.0, x2 - x1) == 0: + x2 = 0.0 + if qwtFuzzyCompare(y1, 0.0, y2 - y1) == 0: + y1 = 0.0 + if qwtFuzzyCompare(y2, 0.0, y2 - y1) == 0: + y2 = 0.0 + return QRectF(x1, y1, x2 - x1, y2 - y1) else: - raise TypeError("%s().transform() takes 1 or 3 argument(s) (%s "\ - "given)" % (self.__class__.__name__, len(args))) + raise TypeError( + "%s().transform() takes 1 or 3 argument(s) (%s " + "given)" % (self.__class__.__name__, len(args)) + ) def invTransform(self, *args): """Transform from paint to scale coordinates - + Scalar: scalemap.invTransform(scalar) Point (QPointF): scalemap.invTransform(xMap, yMap, pos) Rectangle (QRectF): scalemap.invTransform(xMap, yMap, rect) @@ -162,13 +297,12 @@ def invTransform(self, *args): return self.invTransform_scalar(args[0]) elif isinstance(args[2], QPointF): xMap, yMap, pos = args - return QPointF(xMap.invTransform(pos.x()), - yMap.invTransform(pos.y())) + return QPointF(xMap.invTransform(pos.x()), yMap.invTransform(pos.y())) elif isinstance(args[2], QRectF): xMap, yMap, rect = args x1 = xMap.invTransform(rect.left()) - x2 = xMap.invTransform(rect.right()-1) + x2 = xMap.invTransform(rect.right()) y1 = yMap.invTransform(rect.top()) - y2 = yMap.invTransform(rect.bottom()-1) - r = QRectF(x1, y1, x2-x1, y2-y1) + y2 = yMap.invTransform(rect.bottom()) + r = QRectF(x1, y1, x2 - x1, y2 - y1) return r.normalized() diff --git a/qwt/scale_widget.py b/qwt/scale_widget.py index cde3a14..fb3eac6 100644 --- a/qwt/scale_widget.py +++ b/qwt/scale_widget.py @@ -5,19 +5,26 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) +""" +QwtScaleWidget +-------------- + +.. autoclass:: QwtScaleWidget + :members: +""" + +import math + +from qtpy.QtCore import QObject, QRectF, QSize, Qt, Signal +from qtpy.QtGui import QPainter, QPalette +from qtpy.QtWidgets import QSizePolicy, QStyle, QStyleOption, QWidget + +from qwt.color_map import QwtColorMap, QwtLinearColorMap +from qwt.interval import QwtInterval +from qwt.painter import QwtPainter from qwt.scale_draw import QwtScaleDraw from qwt.scale_engine import QwtLinearScaleEngine -from qwt.color_map import QwtLinearColorMap from qwt.text import QwtText -from qwt.painter import QwtPainter -from qwt.interval import QwtInterval -from qwt.color_map import QwtColorMap - -from qwt.qt.QtGui import (QWidget, QSizePolicy, QPainter, QStyleOption, QStyle, - QPalette) -from qwt.qt.QtCore import Qt, QRectF, QSize, Signal - -import numpy as np class ColorBar(object): @@ -27,8 +34,11 @@ def __init__(self): self.interval = QwtInterval() self.colorMap = QwtColorMap() -class QwtScaleWidget_PrivateData(object): + +class QwtScaleWidget_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.scaleDraw = None self.borderDist = [None] * 2 self.minBorderDist = [None] * 2 @@ -42,28 +52,58 @@ def __init__(self): class QwtScaleWidget(QWidget): - - SIG_SCALE_DIV_CHANGED = Signal() - + """ + A Widget which contains a scale + + This Widget can be used to decorate composite widgets with + a scale. + + Layout flags: + + * `QwtScaleWidget.TitleInverted`: The title of vertical scales is painted from top to bottom. Otherwise it is painted from bottom to top. + + .. py:class:: QwtScaleWidget([parent=None]) + + Alignment default is `QwtScaleDraw.LeftScale`. + + :param parent: Parent widget + :type parent: QWidget or None + + .. py:class:: QwtScaleWidget(align, parent) + :noindex: + + :param int align: Alignment + :param QWidget parent: Parent widget + """ + + scaleDivChanged = Signal() + # enum LayoutFlag TitleInverted = 1 - + def __init__(self, *args): self.__data = None align = QwtScaleDraw.LeftScale if len(args) == 0: parent = None elif len(args) == 1: - parent, = args + (parent,) = args elif len(args) == 2: align, parent = args else: - raise TypeError("%s() takes 0, 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s() takes 0, 1 or 2 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) super(QwtScaleWidget, self).__init__(parent) self.initScale(align) - + def initScale(self, align): + """ + Initialize the scale + + :param int align: Alignment + """ self.__data = QwtScaleWidget_PrivateData() self.__data.layoutFlags = 0 if align == QwtScaleDraw.RightScale: @@ -78,39 +118,71 @@ def initScale(self, align): self.__data.scaleDraw = QwtScaleDraw() self.__data.scaleDraw.setAlignment(align) self.__data.scaleDraw.setLength(10) - + self.__data.scaleDraw.setScaleDiv( - QwtLinearScaleEngine().divideScale(0.0, 100.0, 10, 5)) - + QwtLinearScaleEngine().divideScale(0.0, 100.0, 10, 5) + ) + self.__data.colorBar.colorMap = QwtLinearColorMap() self.__data.colorBar.isEnabled = False self.__data.colorBar.width = 10 - - flags = Qt.AlignmentFlag(Qt.AlignHCenter|Qt.TextExpandTabs|Qt.TextWordWrap) + + flags = Qt.AlignmentFlag(Qt.AlignHCenter | Qt.TextExpandTabs | Qt.TextWordWrap) self.__data.title.setRenderFlags(flags) self.__data.title.setFont(self.font()) - + policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) if self.__data.scaleDraw.orientation() == Qt.Vertical: policy.transpose() - + self.setSizePolicy(policy) - + self.setAttribute(Qt.WA_WState_OwnSizePolicy, False) - + def setLayoutFlag(self, flag, on=True): + """ + Toggle an layout flag + + :param int flag: Layout flag + :param bool on: True/False + + .. seealso:: + + :py:meth:`testLayoutFlag()` + """ if (self.__data.layoutFlags & flag != 0) != on: if on: self.__data.layoutFlags |= flag else: self.__data.layoutFlags &= ~flag - + self.update() + def testLayoutFlag(self, flag): + """ + Test a layout flag + + :param int flag: Layout flag + :return: True/False + + .. seealso:: + + :py:meth:`setLayoutFlag()` + """ return self.__data.layoutFlags & flag - + def setTitle(self, title): + """ + Give title new text contents + + :param title: New title + :type title: qwt.text.QwtText or str + + .. seealso:: + + :py:meth:`title()` + """ if isinstance(title, QwtText): - flags = title.renderFlags() & (~ int(Qt.AlignTop|Qt.AlignBottom)) + flags = title.renderFlags() & (~int(Qt.AlignTop | Qt.AlignBottom)) title.setRenderFlags(flags) if title != self.__data.title: self.__data.title = title @@ -119,51 +191,144 @@ def setTitle(self, title): if self.__data.title.text() != title: self.__data.title.setText(title) self.layoutScale() - + def setAlignment(self, alignment): + """ + Change the alignment + + :param int alignment: New alignment + + Valid alignment values: see :py:class:`qwt.scale_draw.QwtScaleDraw` + + .. seealso:: + + :py:meth:`alignment()` + """ if self.__data.scaleDraw: self.__data.scaleDraw.setAlignment(alignment) if not self.testAttribute(Qt.WA_WState_OwnSizePolicy): - policy = QSizePolicy(QSizePolicy.MinimumExpanding, - QSizePolicy.Fixed) + policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) if self.__data.scaleDraw.orientation() == Qt.Vertical: policy.transpose() self.setSizePolicy(policy) self.setAttribute(Qt.WA_WState_OwnSizePolicy, False) self.layoutScale() - + def alignment(self): + """ + :return: position + + .. seealso:: + + :py:meth:`setAlignment()` + """ if not self.scaleDraw(): return QwtScaleDraw.LeftScale return self.scaleDraw().alignment() - + def setBorderDist(self, dist1, dist2): - if dist1 != self.__data.borderDist[0] or\ - dist2 != self.__data.borderDist[1]: + """ + Specify distances of the scale's endpoints from the + widget's borders. The actual borders will never be less + than minimum border distance. + + :param int dist1: Left or top Distance + :param int dist2: Right or bottom distance + + .. seealso:: + + :py:meth:`borderDist()` + """ + if dist1 != self.__data.borderDist[0] or dist2 != self.__data.borderDist[1]: self.__data.borderDist = [dist1, dist2] self.layoutScale() - + def setMargin(self, margin): + """ + Specify the margin to the colorBar/base line. + + :param int margin: Margin + + .. seealso:: + + :py:meth:`margin()` + """ margin = max([0, margin]) if margin != self.__data.margin: self.__data.margin = margin self.layoutScale() - + def setSpacing(self, spacing): + """ + Specify the distance between color bar, scale and title + + :param int spacing: Spacing + + .. seealso:: + + :py:meth:`spacing()` + """ spacing = max([0, spacing]) if spacing != self.__data.spacing: self.__data.spacing = spacing self.layoutScale() - + def setLabelAlignment(self, alignment): + """ + Change the alignment for the labels. + + :param int spacing: Spacing + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtScaleDraw.setLabelAlignment()`, + :py:meth:`setLabelRotation()` + """ self.__data.scaleDraw.setLabelAlignment(alignment) self.layoutScale() - + def setLabelRotation(self, rotation): + """ + Change the rotation for the labels. + + :param float rotation: Rotation + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtScaleDraw.setLabelRotation()`, + :py:meth:`setLabelFlags()` + """ self.__data.scaleDraw.setLabelRotation(rotation) self.layoutScale() - + + def setLabelAutoSize(self, state): + """ + Set the automatic size option for labels (default: on). + + :param bool state: On/off + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtScaleDraw.setLabelAutoSize()` + """ + self.__data.scaleDraw.setLabelAutoSize(state) + self.layoutScale() + def setScaleDraw(self, scaleDraw): + """ + Set a scale draw + + scaleDraw has to be created with new and will be deleted in + class destructor or the next call of `setScaleDraw()`. + scaleDraw will be initialized with the attributes of + the previous scaleDraw object. + + :param qwt.scale_draw.QwtScaleDraw scaleDraw: ScaleDraw object + + .. seealso:: + + :py:meth:`scaleDraw()` + """ if scaleDraw is None or scaleDraw == self.__data.scaleDraw: return sd = self.__data.scaleDraw @@ -176,25 +341,67 @@ def setScaleDraw(self, scaleDraw): scaleDraw.setTransformation(transform) self.__data.scaleDraw = scaleDraw self.layoutScale() - + def scaleDraw(self): + """ + :return: scaleDraw of this scale + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtScaleDraw.setScaleDraw()` + """ return self.__data.scaleDraw - + def title(self): + """ + :return: title + + .. seealso:: + + :py:meth:`setTitle` + """ return self.__data.title - + def startBorderDist(self): + """ + :return: start border distance + + .. seealso:: + + :py:meth:`setBorderDist` + """ return self.__data.borderDist[0] - + def endBorderDist(self): + """ + :return: end border distance + + .. seealso:: + + :py:meth:`setBorderDist` + """ return self.__data.borderDist[1] def margin(self): + """ + :return: margin + + .. seealso:: + + :py:meth:`setMargin` + """ return self.__data.margin - + def spacing(self): + """ + :return: distance between scale and title + + .. seealso:: + + :py:meth:`setSpacing` + """ return self.__data.spacing - + def paintEvent(self, event): painter = QPainter(self) painter.setClipRegion(event.region()) @@ -202,26 +409,39 @@ def paintEvent(self, event): opt.initFrom(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) self.draw(painter) - + def draw(self, painter): + """ + Draw the scale + + :param QPainter painter: Painter + """ self.__data.scaleDraw.draw(painter, self.palette()) - if self.__data.colorBar.isEnabled and\ - self.__data.colorBar.width > 0 and\ - self.__data.colorBar.interval.isValid(): + if ( + self.__data.colorBar.isEnabled + and self.__data.colorBar.width > 0 + and self.__data.colorBar.interval.isValid() + ): self.drawColorBar(painter, self.colorBarRect(self.contentsRect())) - - r = self.contentsRect() + + r = QRectF(self.contentsRect()) if self.__data.scaleDraw.orientation() == Qt.Horizontal: r.setLeft(r.left() + self.__data.borderDist[0]) r.setWidth(r.width() - self.__data.borderDist[1]) else: r.setTop(r.top() + self.__data.borderDist[0]) r.setHeight(r.height() - self.__data.borderDist[1]) - + if not self.__data.title.isEmpty(): self.drawTitle(painter, self.__data.scaleDraw.alignment(), r) - + def colorBarRect(self, rect): + """ + Calculate the the rectangle for the color bar + + :param QRectF rect: Bounding rectangle for all components of the scale + :return: Rectangle for the color bar + """ cr = QRectF(rect) if self.__data.scaleDraw.orientation() == Qt.Horizontal: cr.setLeft(cr.left() + self.__data.borderDist[0]) @@ -231,40 +451,45 @@ def colorBarRect(self, rect): cr.setHeight(cr.height() - self.__data.borderDist[1] + 1) sda = self.__data.scaleDraw.alignment() if sda == QwtScaleDraw.LeftScale: - cr.setLeft(cr.right()-self.__data.margin-self.__data.colorBar.width) + cr.setLeft(cr.right() - self.__data.margin - self.__data.colorBar.width) cr.setWidth(self.__data.colorBar.width) elif sda == QwtScaleDraw.RightScale: - cr.setLeft(cr.left()+self.__data.margin) + cr.setLeft(cr.left() + self.__data.margin) cr.setWidth(self.__data.colorBar.width) elif sda == QwtScaleDraw.BottomScale: - cr.setTop(cr.top()+self.__data.margin) + cr.setTop(cr.top() + self.__data.margin) cr.setHeight(self.__data.colorBar.width) elif sda == QwtScaleDraw.TopScale: - cr.setTop(cr.bottom()-self.__data.margin-self.__data.colorBar.width) + cr.setTop(cr.bottom() - self.__data.margin - self.__data.colorBar.width) cr.setHeight(self.__data.colorBar.width) return cr - + def resizeEvent(self, event): self.layoutScale(False) - + def layoutScale(self, update_geometry=True): + """ + Recalculate the scale's geometry and layout based on + the current geometry and fonts. + + :param bool update_geometry: Notify the layout system and call update to redraw the scale + """ bd0, bd1 = self.getBorderDistHint() if self.__data.borderDist[0] > bd0: bd0 = self.__data.borderDist[0] if self.__data.borderDist[1] > bd1: bd1 = self.__data.borderDist[1] - + colorBarWidth = 0 - if self.__data.colorBar.isEnabled and\ - self.__data.colorBar.interval.isValid(): + if self.__data.colorBar.isEnabled and self.__data.colorBar.interval.isValid(): colorBarWidth = self.__data.colorBar.width + self.__data.spacing - + r = self.contentsRect() if self.__data.scaleDraw.orientation() == Qt.Vertical: y = r.top() + bd0 - length = r.height() - (bd0 +bd1) + length = r.height() - (bd0 + bd1) if self.__data.scaleDraw.alignment() == QwtScaleDraw.LeftScale: - x = r.right() - 1. - self.__data.margin - colorBarWidth + x = r.right() - 1.0 - self.__data.margin - colorBarWidth else: x = r.left() + self.__data.margin + colorBarWidth else: @@ -273,160 +498,356 @@ def layoutScale(self, update_geometry=True): if self.__data.scaleDraw.alignment() == QwtScaleDraw.BottomScale: y = r.top() + self.__data.margin + colorBarWidth else: - y = r.bottom() - 1. - self.__data.margin - colorBarWidth - + y = r.bottom() - 1.0 - self.__data.margin - colorBarWidth + self.__data.scaleDraw.move(x, y) self.__data.scaleDraw.setLength(length) - - extent = np.ceil(self.__data.scaleDraw.extent(self.font())) - self.__data.titleOffset = self.__data.margin + self.__data.spacing +\ - colorBarWidth + extent - + + extent = math.ceil(self.__data.scaleDraw.extent(self.font())) + self.__data.titleOffset = ( + self.__data.margin + self.__data.spacing + colorBarWidth + extent + ) + if update_geometry: self.updateGeometry() + + # The following was removed because it caused a high CPU usage + # in guiqwt.ImageWidget. The origin of these lines was an + # attempt to transpose PythonQwt from Qwt 6.1.2 to Qwt 6.1.5. + + # --> Begin of removed lines <-------------------------------------- + # # for some reason updateGeometry does not send a LayoutRequest + # # event when the parent is not visible and has no layout + # widget = self.parentWidget() + # if widget and not widget.isVisible() and widget.layout() is None: + # if widget.testAttribute(Qt.WA_WState_Polished): + # QApplication.postEvent( + # self.parentWidget(), QEvent(QEvent.LayoutRequest) + # ) + # --> End of removed lines <---------------------------------------- + self.update() - + def drawColorBar(self, painter, rect): + """ + Draw the color bar of the scale widget + + :param QPainter painter: Painter + :param QRectF rect: Bounding rectangle for the color bar + + .. seealso:: + + :py:meth:`setColorBarEnabled()` + """ if not self.__data.colorBar.interval.isValid(): return sd = self.__data.scaleDraw - QwtPainter.drawColorBar(painter, self.__data.colorBar.colorMap, - self.__data.colorBar.interval.normalized(), - sd.scaleMap(), sd.orientation(), rect) - + QwtPainter.drawColorBar( + painter, + self.__data.colorBar.colorMap, + self.__data.colorBar.interval.normalized(), + sd.scaleMap(), + sd.orientation(), + rect, + ) + def drawTitle(self, painter, align, rect): + """ + Rotate and paint a title according to its position into a given rectangle. + + :param QPainter painter: Painter + :param int align: Alignment + :param QRectF rect: Bounding rectangle + """ r = rect - flags = self.__data.title.renderFlags()\ - &(~ int(Qt.AlignTop|Qt.AlignBottom|Qt.AlignVCenter)) + flags = self.__data.title.renderFlags() & ( + ~int(Qt.AlignTop | Qt.AlignBottom | Qt.AlignVCenter) + ) if align == QwtScaleDraw.LeftScale: - angle = -90. + angle = -90.0 flags |= Qt.AlignTop - r.setRect(r.left(), r.bottom(), r.height(), - r.width()-self.__data.titleOffset) + r.setRect( + r.left(), r.bottom(), r.height(), r.width() - self.__data.titleOffset + ) elif align == QwtScaleDraw.RightScale: - angle = -90. + angle = -90.0 flags |= Qt.AlignTop - r.setRect(r.left()+self.__data.titleOffset, r.bottom(), r.height(), - r.width()-self.__data.titleOffset) + r.setRect( + r.left() + self.__data.titleOffset, + r.bottom(), + r.height(), + r.width() - self.__data.titleOffset, + ) elif align == QwtScaleDraw.BottomScale: - angle = 0. + angle = 0.0 flags |= Qt.AlignBottom - r.setTop(r.top()+self.__data.titleOffset) + r.setTop(r.top() + self.__data.titleOffset) else: - angle = 0. + angle = 0.0 flags |= Qt.AlignTop - r.setBottom(r.bottom()-self.__data.titleOffset) - + r.setBottom(r.bottom() - self.__data.titleOffset) + if self.__data.layoutFlags & self.TitleInverted: if align in (QwtScaleDraw.LeftScale, QwtScaleDraw.RightScale): angle = -angle - r.setRect(r.x()+r.height(), r.y()-r.width(), - r.width(), r.height()) - + r.setRect(r.x() + r.height(), r.y() - r.width(), r.width(), r.height()) + painter.save() painter.setFont(self.font()) painter.setPen(self.palette().color(QPalette.Text)) - + painter.translate(r.x(), r.y()) - if angle != 0.: + if angle != 0.0: painter.rotate(angle) - + title = self.__data.title title.setRenderFlags(flags) - title.draw(painter, QRectF(0., 0., r.width(), r.height())) - + title.draw(painter, QRectF(0.0, 0.0, r.width(), r.height())) + painter.restore() - + def scaleChange(self): + """ + Notify a change of the scale + + This method can be overloaded by derived classes. The default + implementation updates the geometry and repaints the widget. + """ self.layoutScale() - + def sizeHint(self): return self.minimumSizeHint() - + def minimumSizeHint(self): o = self.__data.scaleDraw.orientation() length = 0 mbd1, mbd2 = self.getBorderDistHint() - length += max([0, self.__data.borderDist[0]-mbd1]) - length += max([0, self.__data.borderDist[1]-mbd2]) + length += max([0, self.__data.borderDist[0] - mbd1]) + length += max([0, self.__data.borderDist[1] - mbd2]) length += self.__data.scaleDraw.minLength(self.font()) - + dim = self.dimForLength(length, self.font()) if length < dim: length = dim dim = self.dimForLength(length, self.font()) - - size = QSize(length+2, dim) + + size = QSize(length + 2, dim) if o == Qt.Vertical: size.transpose() - - left, right, top, bottom = self.getContentsMargins() + + if self.layout() is None: + left, top, right, bottom = 0, 0, 0, 0 + else: + mgn = self.layout().contentsMargins() + left, top, right, bottom = ( + mgn.left(), + mgn.top(), + mgn.right(), + mgn.bottom(), + ) return size + QSize(left + right, top + bottom) - + def titleHeightForWidth(self, width): - return np.ceil(self.__data.title.heightForWidth(width, self.font())) - + """ + Find the height of the title for a given width. + + :param int width: Width + :return: Height + """ + return math.ceil(self.__data.title.heightForWidth(width, self.font())) + def dimForLength(self, length, scaleFont): - extent = np.ceil(self.__data.scaleDraw.extent(scaleFont)) + """ + Find the minimum dimension for a given length. + dim is the height, length the width seen in direction of the title. + + :param int length: width for horizontal, height for vertical scales + :param QFont scaleFont: Font of the scale + :return: height for horizontal, width for vertical scales + """ + extent = math.ceil(self.__data.scaleDraw.extent(scaleFont)) dim = self.__data.margin + extent + 1 if not self.__data.title.isEmpty(): - dim += self.titleHeightForWidth(length)+self.__data.spacing + dim += self.titleHeightForWidth(length) + self.__data.spacing if self.__data.colorBar.isEnabled and self.__data.colorBar.interval.isValid(): - dim += self.__data.colorBar.width+self.__data.spacing + dim += self.__data.colorBar.width + self.__data.spacing return dim - + def getBorderDistHint(self): + """ + Calculate a hint for the border distances. + + This member function calculates the distance + of the scale's endpoints from the widget borders which + is required for the mark labels to fit into the widget. + The maximum of this distance an the minimum border distance + is returned. + + :param int start: Return parameter for the border width at the beginning of the scale + :param int end: Return parameter for the border width at the end of the scale + + .. warning:: + + The minimum border distance depends on the font. + + .. seealso:: + + :py:meth:`setMinBorderDist()`, :py:meth:`getMinBorderDist()`, + :py:meth:`setBorderDist()` + """ start, end = self.__data.scaleDraw.getBorderDistHint(self.font()) if start < self.__data.minBorderDist[0]: start = self.__data.minBorderDist[0] if end < self.__data.minBorderDist[1]: end = self.__data.minBorderDist[1] return start, end - + def setMinBorderDist(self, start, end): + """ + Set a minimum value for the distances of the scale's endpoints from + the widget borders. This is useful to avoid that the scales + are "jumping", when the tick labels or their positions change + often. + + :param int start: Minimum for the start border + :param int end: Minimum for the end border + + .. seealso:: + + :py:meth:`getMinBorderDist()`, :py:meth:`getBorderDistHint()` + """ self.__data.minBorderDist = [start, end] - + def getMinBorderDist(self): + """ + Get the minimum value for the distances of the scale's endpoints from + the widget borders. + + :param int start: Return parameter for the border width at the beginning of the scale + :param int end: Return parameter for the border width at the end of the scale + + .. seealso:: + + :py:meth:`setMinBorderDist()`, :py:meth:`getBorderDistHint()` + """ return self.__data.minBorderDist - + def setScaleDiv(self, scaleDiv): + """ + Assign a scale division + + The scale division determines where to set the tick marks. + + :param qwt.scale_div.QwtScaleDiv scaleDiv: Scale Division + + .. seealso:: + + For more information about scale divisions, + see :py:class:`qwt.scale_div.QwtScaleDiv`. + """ sd = self.__data.scaleDraw if sd.scaleDiv() != scaleDiv: sd.setScaleDiv(scaleDiv) self.layoutScale() - self.SIG_SCALE_DIV_CHANGED.emit() + self.scaleDivChanged.emit() def setTransformation(self, transformation): + """ + Set the transformation + + :param qwt.transform.Transform transformation: Transformation + + .. seealso:: + + :py:meth:`qwt.scale_draw.QwtAbstractScaleDraw.scaleDraw()`, + :py:class:`qwt.scale_map.QwtScaleMap` + """ self.__data.scaleDraw.setTransformation(transformation) self.layoutScale() - + def setColorBarEnabled(self, on): + """ + En/disable a color bar associated to the scale + + :param bool on: On/Off + + .. seealso:: + + :py:meth:`isColorBarEnabled()`, :py:meth:`setColorBarWidth()` + """ if on != self.__data.colorBar.isEnabled: self.__data.colorBar.isEnabled = on self.layoutScale() - + def isColorBarEnabled(self): + """ + :return: True, when the color bar is enabled + + .. seealso:: + + :py:meth:`setColorBarEnabled()`, :py:meth:`setColorBarWidth()` + """ return self.__data.colorBar.isEnabled - + def setColorBarWidth(self, width): + """ + Set the width of the color bar + + :param int width: Width + + .. seealso:: + + :py:meth:`colorBarWidth()`, :py:meth:`setColorBarEnabled()` + """ if width != self.__data.colorBar.width: self.__data.colorBar.width = width if self.isColorBarEnabled(): self.layoutScale() - + def colorBarWidth(self): + """ + :return: Width of the color bar + + .. seealso:: + + :py:meth:`setColorBarWidth()`, :py:meth:`setColorBarEnabled()` + """ return self.__data.colorBar.width - + def colorBarInterval(self): + """ + :return: Value interval for the color bar + + .. seealso:: + + :py:meth:`setColorMap()`, :py:meth:`colorMap()` + """ return self.__data.colorBar.interval - + def setColorMap(self, interval, colorMap): + """ + Set the color map and value interval, that are used for displaying + the color bar. + + :param qwt.interval.QwtInterval interval: Value interval + :param qwt.color_map.QwtColorMap colorMap: Color map + + .. seealso:: + + :py:meth:`colorMap()`, :py:meth:`colorBarInterval()` + """ self.__data.colorBar.interval = interval if colorMap != self.__data.colorBar.colorMap: self.__data.colorBar.colorMap = colorMap if self.isColorBarEnabled(): self.layoutScale() - + def colorMap(self): - return self.__data.colorBar.colorMap + """ + :return: Color map + .. seealso:: + + :py:meth:`setColorMap()`, :py:meth:`colorBarInterval()` + """ + return self.__data.colorBar.colorMap diff --git a/qwt/series_data.py b/qwt/series_data.py deleted file mode 100644 index 693c20f..0000000 --- a/qwt/series_data.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.sample import QwtIntervalSample, QwtSetSample, QwtOHLCSample - -from qwt.qt.QtCore import QRectF, QPointF - - -class QwtPoint3d(object): - pass #TODO: Fake / to be implemented - -class QwtPointPolar(object): - pass #TODO Fake / to be implemented - - -def qwtBoundingRect(*args): - if args: - sample = args[0] - if len(args) == 1 and isinstance(sample, (QPointF, QwtPoint3d)): - return QRectF(sample.x(), sample.y(), 0.0, 0.0) - elif len(args) == 1 and isinstance(sample, QwtPointPolar): - return QRectF(sample.x(), sample.y(), 0.0, 0.0) - elif len(args) == 1 and isinstance(sample, QwtIntervalSample): - return QRectF(sample.interval.minValue(), sample.value, - sample.interval.maxValue()-sample.interval.minValue(), 0.) - elif len(args) == 1 and isinstance(sample, QwtSetSample): - minY = sample.set[0] - maxY = sample.set[0] - for val in sample.set: - if val < minY: - minY = val - if val > maxY: - maxY = val - minX = sample.value - maxX = sample.value - return QRectF(minX, minY, maxX-minX, maxY-minY) - elif len(args) == 1 and isinstance(sample, QwtOHLCSample): - interval = sample.boundingInterval() - return QRectF(interval.minValue(), sample.time, interval.width(), 0.) - elif len(args) in (1, 2, 3): - series = args[0] - from_ = 0 - to = -1 - if len(args) > 1: - from_ = args[1] - if len(args) > 2: - to = args[2] - return qwtBoundingRectT(series, from_, to) - else: - raise TypeError("%s() takes 1 or 3 argument(s) (%s given)"\ - % ("qwtBoundingRect", len(args))) - - -def qwtBoundingRectT(series, from_, to): - boundingRect = QRectF(1.0, 1.0, -2.0, -2.0) - if from_ < 0: - from_ = 0 - if to < 0: - to = series.size()-1 - if to < from_: - return boundingRect - first_stage = True - for i in range(from_, to+1): - rect = qwtBoundingRect(series.sample(i)) - if rect.width() >= 0. and rect.height() >= 0.: - if first_stage: - boundingRect = rect - first_stage = False - continue - else: - boundingRect.setLeft(min([boundingRect.left(), rect.left()])) - boundingRect.setRight(max([boundingRect.right(), rect.right()])) - boundingRect.setTop(min([boundingRect.top(), rect.top()])) - boundingRect.setBottom(max([boundingRect.bottom(), rect.bottom()])) - return boundingRect - - -class QwtSeriesData(object): - def __init__(self): - self._boundingRect = QRectF(0.0, 0.0, -1.0, -1.0) - - def setRectOfInterest(self, rect): - raise NotImplementedError - - def size(self): - raise NotImplementedError - - def sample(self, i): - raise NotImplementedError - - -class QwtArraySeriesData(QwtSeriesData): - def __init__(self, samples=None): - QwtSeriesData.__init__(self) - self.__samples = [] - if samples is not None: - self.__samples = samples - - def setSamples(self, samples): - self._boundingRect = QRectF(0.0, 0.0, -1.0, -1.0) - self.__samples = samples - - def samples(self): - return self.__samples - - def size(self): - return len(self.__samples) - - def sample(self, i): - return self.__samples[i] - - -class QwtPointSeriesData(QwtArraySeriesData): - def __init__(self, samples=[]): - QwtArraySeriesData.__init__(self, samples) - - def boundingRect(self): - if self._boundingRect.width() < 0.: - self._boundingRect = qwtBoundingRect(self) - return self._boundingRect - - -class QwtPoint3DSeriesData(QwtArraySeriesData): - def __init__(self, samples): - QwtArraySeriesData.__init__(self, samples) - - def boundingRect(self): - if self._boundingRect.width() < 0.: - self._boundingRect = qwtBoundingRect(self) - return self._boundingRect - - -class QwtIntervalSeriesData(QwtArraySeriesData): - def __init__(self, samples=None): - QwtArraySeriesData.__init__(self, samples) - - def boundingRect(self): - if self._boundingRect.width() < 0.: - self._boundingRect = qwtBoundingRect(self) - return self._boundingRect - - -class QwtSetSeriesData(QwtArraySeriesData): - def __init__(self, samples): - QwtArraySeriesData.__init__(self, samples) - - def boundingRect(self): - if self._boundingRect.width() < 0.: - self._boundingRect = qwtBoundingRect(self) - return self._boundingRect - - -class QwtTradingChartData(QwtArraySeriesData): - def __init__(self, samples): - QwtArraySeriesData.__init__(self, samples) - - def boundingRect(self): - if self._boundingRect.width() < 0.: - self._boundingRect = qwtBoundingRect(self) - return self._boundingRect - diff --git a/qwt/series_store.py b/qwt/series_store.py deleted file mode 100644 index a8f6261..0000000 --- a/qwt/series_store.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.qt.QtCore import QRectF - - -class QwtAbstractSeriesStore(object): - def dataChanged(self): - raise NotImplementedError - - def dataSize(self): - raise NotImplementedError - - def dataRect(self): - raise NotImplementedError - - def setRectOfInterest(self, rect): - raise NotImplementedError - - -class QwtSeriesStore(QwtAbstractSeriesStore): - def __init__(self): - self.__series = None - - def data(self): - return self.__series - - def sample(self, index): - if self.__series: - return self.__series.sample(index) - else: - #TODO: not implemented! - return - - def setData(self, series): - if self.__series != series: - self.__series = series - self.dataChanged() - - def dataSize(self): - if self.__series is None: - return 0 - return self.__series.size() - - def dataRect(self): - if self.__series is None or self.dataSize() == 0: - return QRectF(1.0, 1.0, -2.0, -2.0) - return self.__series.boundingRect() - - def setRectOfInterest(self, rect): - if self.__series: - self.__series.setRectOfInterest(rect) - - def swapData(self, series): - swappedSeries = self.__series - self.__series = series - return swappedSeries diff --git a/qwt/spline.py b/qwt/spline.py deleted file mode 100644 index 386f39c..0000000 --- a/qwt/spline.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.qt.QtGui import QPolygonF - -import numpy as np - - -class QwtSpline_PrivateData(object): - def __init__(self): - self.splineType = QwtSpline.Natural - self.a = np.array([]) - self.b = np.array([]) - self.c = np.array([]) - self.points = QPolygonF() - - -def lookup(x, values): - size = values.size() - if x <= values[0].x(): - i1 = 0 - elif x >= values[size-2].x(): - i1 = size-2 - else: - i1 = 0 - i2 = size-2 - i3 = 0 - while i2-i1 > 1: - i3 = i1 + ((i2-i1) >> 1) - if values[i3].x() > x: - i2 = i3 - else: - i1 = i3 - return i1 - - -class QwtSpline(object): - - # enum SplineType - Natural, Periodic = list(range(2)) - - def __init__(self, other=None): - if other is not None: - self.__data = other.__data - else: - self.__data = QwtSpline_PrivateData() - - def setSplineType(self, splineType): - self.__data.splineType = splineType - - def splineType(self): - return self.__data.splineType - - def setPoints(self, points): - """points: QPolygonF""" - size = points.size() - if size <= 2: - self.reset() - return False - self.__data.points = points - self.__data.a.resize(size-1) - self.__data.b.resize(size-1) - self.__data.c.resize(size-1) - if self.__data.splineType == self.Periodic: - ok = self.buildPeriodicSpline(points) - else: - ok = self.buildNaturalSpline(points) - if not ok: - self.reset() - return ok - - def points(self): - return self.__data.points - - def coefficientsA(self): - return self.__data.a - - def coefficientsB(self): - return self.__data.b - - def coefficientsC(self): - return self.__data.c - - def reset(self): - self.__data.a = np.array([]) - self.__data.b = np.array([]) - self.__data.c = np.array([]) - self.__data.points.resize(0) - - def isValid(self): - return len(self.__data.a) > 0 - - def value(self, x): - if len(self.__data.a) == 0: - return 0. - i = lookup(x, self.__data.points) - delta = x - self.__data.points[i].x() - return ((self.__data.a[i]*delta + self.__data.b[i])\ - *delta + self.__data.c[i])*delta + self.__data.points[i].y() - - def buildNaturalSpline(self, points): - #TODO: to be implemented (!!! performance issue !!!)--> scipy ? - raise NotImplementedError - - def buildPeriodicSpline(self, points): - #TODO: to be implemented (!!! performance issue !!!)--> scipy ? - raise NotImplementedError - - - \ No newline at end of file diff --git a/qwt/symbol.py b/qwt/symbol.py index 2663333..22f7ccb 100644 --- a/qwt/symbol.py +++ b/qwt/symbol.py @@ -1,815 +1,1272 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.graphic import QwtGraphic -from qwt.painter import QwtPainter - -from qwt.qt.QtGui import (QPainter, QTransform, QPixmap, QPen, QPolygonF, - QPainterPath, QBrush, QPaintEngine) -from qwt.qt.QtCore import QSize, QRect, QPointF, QRectF, QSizeF, Qt, QPoint -from qwt.qt.QtSvg import QSvgRenderer - -import numpy as np - - -class QwtTriangle(object): - - # enum Type - Left, Right, Up, Down = list(range(4)) - - -def qwtPathGraphic(path, pen, brush): - graphic = QwtGraphic() - graphic.setRenderHint(QwtGraphic.RenderPensUnscaled) - painter = QPainter(graphic) - painter.setPen(pen) - painter.setBrush(brush) - painter.drawPath(path) - painter.end() - return graphic - - -def qwtScaleBoundingRect(graphic, size): - scaledSize = QSize(size) - if scaledSize.isEmpty(): - scaledSize = graphic.defaultSize() - sz = graphic.controlPointRect().size() - sx = 1. - if sz.width() > 0.: - sx = scaledSize.width()/sz.width() - sy = 1. - if sz.height() > 0.: - sy = scaledSize.height()/sz.height() - return graphic.scaledBoundingRect(sx, sy) - - -def qwtDrawPixmapSymbols(painter, points, numPoints, symbol): - size = symbol.size() - if size.isEmpty(): - size = symbol.pixmap().size() - transform = QTransform(painter.transform()) - if transform.isScaling(): - r = QRect(0, 0, size.width(), size.height()) - size = transform.mapRect(r).size() - pm = QPixmap(symbol.pixmap()) - if pm.size() != size: - pm = pm.scaled(size) - pinPoint = QPointF(.5*size.width(), .5*size.height()) - if symbol.isPinPointEnabled(): - pinPoint = symbol.pinPoint() - painter.resetTransform() - for pos in points: - pos = QPointF(transform.map(pos))-pinPoint - QwtPainter.drawPixmap(painter, QRect(pos.toPoint(), pm.size(), pm)) - - -def qwtDrawSvgSymbols(painter, points, numPoints, renderer, symbol): - if renderer is None or not renderer.isValid(): - return - viewBox = QRectF(renderer.viewBoxF()) - if viewBox.isEmpty(): - return - sz = QSizeF(symbol.size()) - if not sz.isValid(): - sz = viewBox.size() - sx = sz.width()/viewBox.width() - sy = sz.height()/viewBox.height() - pinPoint = QPointF(viewBox.center()) - if symbol.isPinPointEnabled(): - pinPoint = symbol.pinPoint() - dx = sx*(pinPoint.x()-viewBox.left()) - dy = sy*(pinPoint.y()-viewBox.top()) - for pos in points: - x = pos.x()-dx - y = pos.y()-dy - renderer.render(painter, QRectF(x, y, sz.width(), sz.height())) - - -def qwtDrawGraphicSymbols(painter, points, numPoint, graphic, symbol): - pointRect = QRectF(graphic.controlPointRect()) - if pointRect.isEmpty(): - return - sx = 1. - sy = 1. - sz = symbol.size() - if sz.isValid(): - sx = sz.width()/pointRect.width() - sy = sz.height()/pointRect.height() - pinPoint = QPointF(pointRect.center()) - if symbol.isPinPointEnabled(): - pinPoint = symbol.pinPoint() - transform = QTransform(painter.transform()) - for pos in points: - tr = QTransform(transform) - tr.translate(pos.x(), pos.y()) - tr.scale(sx, sy) - tr.translate(-pinPoint.x(), -pinPoint.y()) - painter.setTransform(tr) - graphic.render(painter) - painter.setTransform(transform) - - -def qwtDrawEllipseSymbols(painter, points, numPoints, symbol): - painter.setBrush(symbol.brush()) - painter.setPen(symbol.pen()) - size =symbol.size() - if QwtPainter.roundingAlignment(painter): - sw = size.width() - sh = size.height() - sw2 = size.width()//2 - sh2 = size.height()//2 - for pos in points: - x = round(pos.x()) - y = round(pos.y()) - r = QRectF(x-sw2, y-sh2, sw, sh) - QwtPainter.drawEllipse(painter, r) - else: - sw = size.width() - sh = size.height() - sw2 = .5*size.width() - sh2 = .5*size.height() - for pos in points: - x = pos.x() - y = pos.y() - r = QRectF(x-sw2, y-sh2, sw, sh) - QwtPainter.drawEllipse(painter, r) - - -def qwtDrawRectSymbols(painter, points, numPoints, symbol): - size = symbol.size() - pen = QPen(symbol.pen()) - pen.setJoinStyle(Qt.MiterJoin) - painter.setPen(pen) - painter.setBrush(symbol.brush()) - painter.setRenderHint(QPainter.Antialiasing, False) - if QwtPainter.roundingAlignment(painter): - sw = size.width() - sh = size.height() - sw2 = size.width()//2 - sh2 = size.height()//2 - for pos in points: - x = round(pos.x()) - y = round(pos.y()) - r = QRectF(x-sw2, y-sh2, sw, sh) - QwtPainter.drawRect(painter, r) - else: - sw = size.width() - sh = size.height() - sw2 = .5*size.width() - sh2 = .5*size.height() - for pos in points: - x = pos.x() - y = pos.y() - r = QRectF(x-sw2, y-sh2, sw, sh) - QwtPainter.drawRect(painter, r) - - -def qwtDrawDiamondSymbols(painter, points, numPoints, symbol): - size =symbol.size() - pen = QPen(symbol.pen()) - pen.setJoinStyle(Qt.MiterJoin) - painter.setPen(pen) - painter.setBrush(symbol.brush()) - if QwtPainter.roundingAlignment(painter): - for pos in points: - x = round(pos.x()) - y = round(pos.y()) - x1 = x-size.width()//2 - y1 = y-size.height()//2 - x2 = x1+size.width() - y2 = y1+size.height() - polygon = QPolygonF() - polygon += QPointF(x, y1) - polygon += QPointF(x1, y) - polygon += QPointF(x, y2) - polygon += QPointF(x2, y) - QwtPainter.drawPolygon(painter, polygon) - else: - for pos in points: - x1 = pos.x()-.5*size.width() - y1 = pos.y()-.5*size.height() - x2 = x1+size.width() - y2 = y1+size.height() - polygon = QPolygonF() - polygon += QPointF(pos.x(), y1) - polygon += QPointF(x1, pos.y()) - polygon += QPointF(pos.x(), y2) - polygon += QPointF(x2, pos.y()) - QwtPainter.drawPolygon(painter, polygon) - - -def qwtDrawTriangleSymbols(painter, type, points, numPoint, symbol): - size =symbol.size() - pen = QPen(symbol.pen()) - pen.setJoinStyle(Qt.MiterJoin) - painter.setPen(pen) - painter.setBrush(symbol.brush()) - doAlign = QwtPainter.roundingAlignment(painter) - sw2 = .5*size.width() - sh2 = .5*size.height() - if doAlign: - sw2 = np.floor(sw2) - sh2 = np.floor(sh2) - for pos in points: - x = pos.x() - y = pos.y() - if doAlign: - x = round(x) - y = round(y) - x1 = x-sw2 - x2 = x1+size.width() - y1 = y-sh2 - y2 = y1+size.height() - if type == QwtTriangle.Left: - triangle = [QPointF(x2, y1), QPointF(x1, y), QPointF(x2, y2)] - elif type == QwtTriangle.Right: - triangle = [QPointF(x1, y1), QPointF(x2, y), QPointF(x1, y2)] - elif type == QwtTriangle.Up: - triangle = [QPointF(x1, y2), QPointF(x, y1), QPointF(x2, y2)] - elif type == QwtTriangle.Down: - triangle = [QPointF(x1, y1), QPointF(x, y2), QPointF(x2, y1)] - QwtPainter.drawPolygon(painter, QPolygonF(triangle)) - - -def qwtDrawLineSymbols(painter, orientations, points, numPoints, symbol): - size =symbol.size() - off = 0 - pen = QPen(symbol.pen()) - if pen.width() > 1: - pen.setCapStyle(Qt.FlatCap) - off = 1 - painter.setPen(pen) - painter.setRenderHint(QPainter.Antialiasing, False) - if QwtPainter.roundingAlignment(painter): - sw = np.floor(size.width()) - sh = np.floor(size.height()) - sw2 = size.width()//2 - sh2 = size.height()//2 - for pos in points: - if orientations & Qt.Horizontal: - x = round(pos.x())-sw2 - y = round(pos.y()) - QwtPainter.drawLine(painter, x, y, x+sw+off, y) - if orientations & Qt.Vertical: - x = round(pos.x()) - y = round(pos.y())-sh2 - QwtPainter.drawLine(painter, x, y, x, y+sh+off) - else: - sw = size.width() - sh = size.height() - sw2 = .5*size.width() - sh2 = .5*size.height() - for pos in points: - if orientations & Qt.Horizontal: - x = round(pos.x())-sw2 - y = round(pos.y()) - QwtPainter.drawLine(painter, x, y, x+sw, y) - if orientations & Qt.Vertical: - x = round(pos.x()) - y = round(pos.y())-sh2 - QwtPainter.drawLine(painter, x, y, x, y+sh) - - -def qwtDrawXCrossSymbols(painter, points, numPoints, symbol): - size =symbol.size() - off = 0 - pen = QPen(symbol.pen()) - if pen.width() > 1: - pen.setCapStyle(Qt.FlatCap) - off = 1 - painter.setPen(pen) - if QwtPainter.roundingAlignment(painter): - sw = np.floor(size.width()) - sh = np.floor(size.height()) - sw2 = size.width()//2 - sh2 = size.height()//2 - for pos in points: - x = round(pos.x()) - y = round(pos.y()) - x1 = x-sw2 - x2 = x1+sw+off - y1 = y-sh2 - y2 = y1+sh+off - QwtPainter.drawLine(painter, x1, y1, x2, y2) - QwtPainter.drawLine(painter, x2, y1, x1, y2) - else: - sw = size.width() - sh = size.height() - sw2 = .5*size.width() - sh2 = .5*size.height() - for pos in points: - x1 = pos.x()-sw2 - x2 = x1+sw - y1 = pos.y()-sh2 - y2 = y1+sh - QwtPainter.drawLine(painter, x1, y1, x2, y2) - QwtPainter.drawLine(painter, x2, y1, x1, y2) - - -def qwtDrawStar1Symbols(painter, points, numPoints, symbol): - size =symbol.size() - painter.setPen(symbol.pen()) - sqrt1_2 = np.sqrt(.5) - if QwtPainter.roundingAlignment(painter): - r = QRect(0, 0, size.width(), size.height()) - for pos in points: - r.moveCenter(pos.toPoint()) - d1 = r.width()/2.*(1.-sqrt1_2) - QwtPainter.drawLine(painter, - round(r.left()+d1), round(r.top()+d1), - round(r.right()-d1), round(r.bottom()-d1)) - QwtPainter.drawLine(painter, - round(r.left()+d1), round(r.bottom()-d1), - round(r.right()-d1), round(r.top()+d1)) - c = QPoint(r.center()) - QwtPainter.drawLine(painter, c.x(), r.top(), c.x(), r.bottom()) - QwtPainter.drawLine(painter, r.left(), c.y(), r.right(), c.y()) - else: - r = QRectF(0, 0, size.width(), size.height()) - for pos in points: - r.moveCenter(pos.toPoint()) - c = QPointF(r.center()) - d1 = r.width()/2.*(1.-sqrt1_2) - QwtPainter.drawLine(painter, r.left()+d1, r.top()+d1, - r.right()-d1, r.bottom()-d1) - QwtPainter.drawLine(painter, r.left()+d1, r.bottom()-d1, - r.right()-d1, r.top()+d1) - QwtPainter.drawLine(painter, c.x(), r.top(), c.x(), r.bottom()) - QwtPainter.drawLine(painter, r.left(), c.y(), r.right(), c.y()) - - -def qwtDrawStar2Symbols(painter, points, numPoints, symbol): - pen = QPen(symbol.pen()) - if pen.width() > 1: - pen.setCapStyle(Qt.FlatCap) - pen.setJoinStyle(Qt.MiterJoin) - painter.setPen(pen) - painter.setBrush(symbol.brush()) - cos30 = np.cos(30*np.pi/180.) - dy = .25*symbol.size().height() - dx = .5*symbol.size().width()*cos30/3. - doAlign = QwtPainter.roundingAlignment(painter) - for pos in points: - if doAlign: - x = round(pos.x()) - y = round(pos.y()) - x1 = round(x-3*dx) - y1 = round(y-2*dy) - else: - x = pos.x() - y = pos.y() - x1 = x-3*dx - y1 = y-2*dy - x2 = x1+1*dx - x3 = x1+2*dx - x4 = x1+3*dx - x5 = x1+4*dx - x6 = x1+5*dx - x7 = x1+6*dx - y2 = y1+1*dy - y3 = y1+2*dy - y4 = y1+3*dy - y5 = y1+4*dy - star = [QPointF(x4, y1), QPointF(x5, y2), QPointF(x7, y2), - QPointF(x6, y3), QPointF(x7, y4), QPointF(x5, y4), - QPointF(x4, y5), QPointF(x3, y4), QPointF(x1, y4), - QPointF(x2, y3), QPointF(x1, y2), QPointF(x3, y2)] - QwtPainter.drawPolygon(painter, QPolygonF(star)) - - -def qwtDrawHexagonSymbols(painter, points, numPoints, symbol): - painter.setBrush(symbol.brush()) - painter.setPen(symbol.pen()) - cos30 = np.cos(30*np.pi/180.) - dx = .5*(symbol.size().width()-cos30) - dy = .25*symbol.size().height() - doAlign = QwtPainter.roundingAlignment(painter) - for pos in points: - if doAlign: - x = round(pos.x()) - y = round(pos.y()) - x1 = np.ceil(x-dx) - y1 = np.ceil(y-2*dy) - else: - x = pos.x() - y = pos.y() - x1 = x-dx - y1 = y-2*dy - x2 = x1+1*dx - x3 = x1+2*dx - y2 = y1+1*dy - y3 = y1+3*dy - y4 = y1+4*dy - hexa = [QPointF(x2, y1), QPointF(x3, y2), QPointF(x3, y3), - QPointF(x2, y4), QPointF(x1, y3), QPointF(x1, y2)] - QwtPainter.drawPolygon(painter, QPolygonF(hexa)) - - -class QwtSymbol_PrivateData(object): - def __init__(self, st, br, pn ,sz): - self.style = st - self.size = sz - self.brush = br - self.pen = pn - self.isPinPointEnabled = False - self.pinPoint = QPointF() - - class Path(object): - def __init__(self): - self.path = QPainterPath() - self.graphic = QwtGraphic() - self.path = Path() - - class Pixmap(object): - def __init__(self): - self.pixmap = QPixmap() - self.pixmap = None #Pixmap() - - class Graphic(object): - def __init__(self): - self.graphic = QwtGraphic() - self.graphic = Graphic() - - class SVG(object): - def __init__(self): - self.renderer = QSvgRenderer() - self.svg = SVG() - - class PaintCache(object): - def __init__(self): - self.policy = 0 - self.pixmap = None #QPixmap() - self.cache = PaintCache() - - -class QwtSymbol(object): - - # enum Style - Style = int - NoSymbol = -1 - (Ellipse, Rect, Diamond, Triangle, DTriangle, UTriangle, LTriangle, - RTriangle, Cross, XCross, HLine, VLine, Star1, Star2, Hexagon, Path, - Pixmap, Graphic, SvgDocument) = list(range(19)) - UserStyle = 1000 - - # enum CachePolicy - NoCache, Cache, AutoCache = list(range(3)) - - def __init__(self, *args): - if len(args) in (0, 1): - if args: - style, = args - else: - style = QwtSymbol.NoSymbol - self.__data = QwtSymbol_PrivateData(style, QBrush(Qt.gray), - QPen(Qt.black, 0), QSize()) - elif len(args) == 4: - style, brush, pen, size = args - self.__data = QwtSymbol_PrivateData(style, brush, pen, size) - elif len(args) == 3: - path, brush, pen = args - self.__data = QwtSymbol_PrivateData(QwtSymbol.Path, brush, pen, - QSize()) - self.setPath(path) - else: - raise TypeError("%s() takes 1, 3, or 4 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def setCachePolicy(self, policy): - if self.__data.cache.policy != policy: - self.__data.cache.policy = policy - self.invalidateCache() - - def cachePolicy(self): - return self.__data.cache.policy - - def setPath(self, path): - self.__data.style = QwtSymbol.Path - self.__data.path.path = path - self.__data.path.graphic.reset() - - def path(self): - return self.__data.path.path - - def setPixmap(self, pixmap): - self.__data.style = QwtSymbol.Pixmap - self.__data.pixmap.pixmap = pixmap - - def pixmap(self): - return self.__data.pixmap.pixmap - - def setGraphic(self, graphic): - self.__data.style = QwtSymbol.Graphic - self.__data.graphic.graphic = graphic - - def graphic(self): - return self.__data.graphic.graphic - - def setSvgDocument(self, svgDocument): - self.__data.style = QwtSymbol.SvgDocument - if self.__data.svg.renderer is None: - self.__data.svg.renderer = QSvgRenderer() - self.__data.svg.renderer.load(svgDocument) - - def setSize(self, *args): - if len(args) == 2: - width, height = args - if width >= 0 and height < 0: - height = width - self.setSize(QSize(width, height)) - elif len(args) == 1: - size, = args - if size.isValid() and size != self.__data.size: - self.__data.size = size - self.invalidateCache() - else: - raise TypeError("%s().setSize() takes 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def size(self): - return self.__data.size - - def setBrush(self, brush): - if brush != self.__data.brush: - self.__data.brush = brush - self.invalidateCache() - if self.__data.style == QwtSymbol.Path: - self.__data.path.graphic.reset() - - def brush(self): - return self.__data.brush - - def setPen(self, *args): - if len(args) == 3: - color, width, style = args - self.setPen(QPen(color, width, style)) - elif len(args) == 1: - pen, = args - if pen != self.__data.pen: - self.__data.pen = pen - self.invalidateCache() - if self.__data.style == QwtSymbol.Path: - self.__data.path.graphic.reset() - else: - raise TypeError("%s().setPen() takes 1 or 3 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - def pen(self): - return self.__data.pen - - def setColor(self, color): - if self.__data.style in (QwtSymbol.Ellipse, QwtSymbol.Rect, - QwtSymbol.Diamond, QwtSymbol.Triangle, - QwtSymbol.UTriangle, QwtSymbol.DTriangle, - QwtSymbol.RTriangle, QwtSymbol.LTriangle, - QwtSymbol.Star2, QwtSymbol.Hexagon): - if self.__data.brush.color() != color: - self.__data.brush.setColor(color) - self.invalidateCache() - elif self.__data.style in (QwtSymbol.Cross, QwtSymbol.XCross, - QwtSymbol.HLine, QwtSymbol.VLine, - QwtSymbol.Star1): - if self.__data.pen.color() != color: - self.__data.pen.setColor(color) - self.invalidateCache() - else: - if self.__data.brush.color() != color or\ - self.__data.pen.color() != color: - self.invalidateCache() - self.__data.brush.setColor(color) - self.__data.pen.setColor(color) - - def setPinPoint(self, pos, enable): - if self.__data.pinPoint != pos: - self.__data.pinPoint = pos - if self.__data.isPinPointEnabled: - self.invalidateCache() - self.setPinPointEnabled(enable) - - def pinPoint(self): - return self.__data.pinPoint - - def setPinPointEnabled(self, on): - if self.__data.isPinPointEnabled != on: - self.__data.isPinPointEnabled = on - self.invalidateCache() - - def isPinPointEnabled(self): - return self.__data.isPinPointEnabled - - def drawSymbols(self, painter, points, numPoints=None): - #TODO: remove argument numPoints (not necessary in Python's qwt) - if numPoints is not None and numPoints <= 0: - return - useCache = False - if QwtPainter.roundingAlignment(painter) and\ - not painter.transform().isScaling(): - if self.__data.cache.policy == QwtSymbol.Cache: - useCache = True - elif self.__data.cache.policy == QwtSymbol.AutoCache: - if painter.paintEngine().type() == QPaintEngine.Raster: - useCache = True - else: - if self.__data.style in (QwtSymbol.XCross, QwtSymbol.HLine, - QwtSymbol.VLine, QwtSymbol.Cross): - pass - elif self.__data.style == QwtSymbol.Pixmap: - if not self.__data.size.isEmpty() and\ - self.__data.size != self.__data.pixmap.pixmap.size(): - useCache = True - else: - useCache = True - if useCache: - br = QRect(self.boundingRect()) - rect = QRect(0, 0, br.width(), br.height()) - if self.__data.cache.pixmap.isNull(): - self.__data.cache.pixmap = QwtPainter.backingStore(None, br.size()) - self.__data.cache.pixmap.fill(Qt.transparent) - p = QPainter(self.__data.cache.pixmap) - p.setRenderHints(painter.renderHints()) - p.translate(-br.topLeft()) - pos = QPointF() - self.renderSymbols(p, pos, 1) - dx = br.left() - dy = br.top() - for point in points: - left = round(point.x())+dx - top = round(point.y())+dy - painter.drawPixmap(left, top, self.__data.cache.pixmap) - else: - painter.save() - self.renderSymbols(painter, points, numPoints) - painter.restore() - - def drawSymbol(self, painter, point_or_rect): - if isinstance(point_or_rect, (QPointF, QPoint)): - # drawSymbol( QPainter *, const QPointF & ) - self.drawSymbols(painter, [point_or_rect], 1) - return - # drawSymbol( QPainter *, const QRectF & ) - rect = point_or_rect - assert isinstance(rect, QRectF) - if self.__data.style == QwtSymbol.NoSymbol: - return - if self.__data.style == QwtSymbol.Graphic: - self.__data.graphic.graphic.render(painter, rect, - Qt.KeepAspectRatio) - elif self.__data.style == QwtSymbol.Path: - if self.__data.path.graphic.isNull(): - self.__data.path.graphic = qwtPathGraphic( - self.__data.path.path, self.__data.pen, self.__data.brush) - self.__data.path.graphic.render(painter, rect, Qt.KeepAspectRatio) - return - elif self.__data.style == QwtSymbol.SvgDocument: - if self.__data.svg.renderer is not None: - scaledRect = QRectF() - sz = QSizeF(self.__data.svg.renderer.viewBoxF().size()) - if not sz.isEmpty(): - sz.scale(rect.size(), Qt.KeepAspectRatio) - scaledRect.setSize(sz) - scaledRect.moveCenter(rect.center()) - else: - scaledRect = rect - self.__data.svg.renderer.render(painter, scaledRect) - else: - br = QRect(self.boundingRect()) - ratio = min([rect.width()/br.width(), rect.height()/br.height()]) - painter.save() - painter.translate(rect.center()) - painter.scale(ratio, ratio) - isPinPointEnabled = self.__data.isPinPointEnabled - self.__data.isPinPointEnabled = False - pos = QPointF() - self.renderSymbols(painter, pos, 1) - self.__data.isPinPointEnabled = isPinPointEnabled - painter.restore() - - def renderSymbols(self, painter, points, numPoints): - if self.__data.style == QwtSymbol.Ellipse: - qwtDrawEllipseSymbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Rect: - qwtDrawRectSymbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Diamond: - qwtDrawDiamondSymbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Cross: - qwtDrawLineSymbols(painter, Qt.Horizontal|Qt.Vertical, - points, numPoints, self) - elif self.__data.style == QwtSymbol.XCross: - qwtDrawXCrossSymbols(painter, points, numPoints, self) - elif self.__data.style in (QwtSymbol.Triangle, QwtSymbol.UTriangle): - qwtDrawTriangleSymbols(painter, QwtTriangle.Up, - points, numPoints, self) - elif self.__data.style == QwtSymbol.DTriangle: - qwtDrawTriangleSymbols(painter, QwtTriangle.Down, - points, numPoints, self) - elif self.__data.style == QwtSymbol.RTriangle: - qwtDrawTriangleSymbols(painter, QwtTriangle.Right, - points, numPoints, self) - elif self.__data.style == QwtSymbol.LTriangle: - qwtDrawTriangleSymbols(painter, QwtTriangle.Left, - points, numPoints, self) - elif self.__data.style == QwtSymbol.HLine: - qwtDrawLineSymbols(painter, Qt.Horizontal, points, numPoints, self) - elif self.__data.style == QwtSymbol.VLine: - qwtDrawLineSymbols(painter, Qt.Vertical, points, numPoints, self) - elif self.__data.style == QwtSymbol.Star1: - qwtDrawStar1Symbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Star2: - qwtDrawStar2Symbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Hexagon: - qwtDrawHexagonSymbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Path: - if self.__data.path.graphic.isNull(): - self.__data.path.graphic = qwtPathGraphic( - self.__data.path.path, self.__data.pen, self.__data.brush) - qwtDrawGraphicSymbols(painter, points, numPoints, - self.__data.path.graphic, self) - elif self.__data.style == QwtSymbol.Pixmap: - qwtDrawPixmapSymbols(painter, points, numPoints, self) - elif self.__data.style == QwtSymbol.Graphic: - qwtDrawGraphicSymbols(painter, points, numPoints, - self.__data.graphic.graphic, self) - elif self.__data.style == QwtSymbol.SvgDocument: - qwtDrawSvgSymbols(painter, points, numPoints, - self.__data.svg.renderer, self) - - def boundingRect(self): - rect = QRectF() - pinPointTranslation = False - if self.__data.style in (QwtSymbol.Ellipse, QwtSymbol.Rect, - QwtSymbol.Hexagon): - pw = 0. - if self.__data.pen.style() != Qt.NoPen: - pw = max([self.__data.pen.widthF(), 1.]) - rect.setSize(self.__data.size+QSizeF(pw, pw)) - rect.moveCenter(QPointF(0., 0.)) - elif self.__data.style in (QwtSymbol.XCross, QwtSymbol.Diamond, - QwtSymbol.Triangle, QwtSymbol.UTriangle, - QwtSymbol.DTriangle, QwtSymbol.RTriangle, - QwtSymbol.LTriangle, QwtSymbol.Star1, - QwtSymbol.Star2): - pw = 0. - if self.__data.pen.style() != Qt.NoPen: - pw = max([self.__data.pen.widthF(), 1.]) - rect.setSize(QSizeF(self.__data.size)+QSizeF(2*pw, 2*pw)) - rect.moveCenter(QPointF(0., 0.)) - elif self.__data.style == QwtSymbol.Path: - if self.__data.path.graphic.isNull(): - self.__data.path.graphic = qwtPathGraphic( - self.__data.path.path, self.__data.pen, self.__data.brush) - rect = qwtScaleBoundingRect(self.__data.path.graphic, - self.__data.size) - pinPointTranslation = True - elif self.__data.style == QwtSymbol.Pixmap: - if self.__data.size.isEmpty(): - rect.setSize(self.__data.pixmap.pixmap.size()) - else: - rect.setSize(self.__data.size) - pinPointTranslation = True - elif self.__data.style == QwtSymbol.Graphic: - rect = qwtScaleBoundingRect(self.__data.graphic.graphic, - self.__data.size) - pinPointTranslation = True - elif self.__data.style == QwtSymbol.SvgDocument: - if self.__data.svg.renderer is not None: - rect = self.__data.svg.renderer.viewBoxF() - if self.__data.size.isValid() and not rect.isEmpty(): - sz = QSizeF(rect.size()) - sx = self.__data.size.width()/sz.width() - sy = self.__data.size.height()/sz.height() - transform = QTransform() - transform.scale(sx, sy) - rect = transform.mapRect(rect) - pinPointTranslation = True - else: - rect.setSize(self.__data.size) - rect.moveCenter(QPointF(0., 0.)) - if pinPointTranslation: - pinPoint = QPointF(0., 0.) - if self.__data.isPinPointEnabled: - pinPoint = rect.center()-self.__data.pinPoint - rect.moveCenter(pinPoint) - r = QRect() - r.setLeft(np.floor(rect.left())) - r.setTop(np.floor(rect.top())) - r.setRight(np.floor(rect.right())) - r.setBottom(np.floor(rect.bottom())) - if self.__data.style != QwtSymbol.Pixmap: - r.adjust(-1, -1, 1, 1) - return r - - def invalidateCache(self): - if self.__data.cache.pixmap is not None: - self.__data.cache.pixmap = QPixmap() - - def setStyle(self, style): - if self.__data.style != style: - self.__data.style = style - self.invalidateCache() - - def style(self): - return self.__data.style +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# Copyright (c) 2002 Uwe Rathmann, for the original C++ code +# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization +# (see LICENSE file for more details) + +""" +QwtSymbol +--------- + +.. autoclass:: QwtSymbol + :members: +""" + +import math + +from qtpy.QtCore import ( + QLineF, + QObject, + QPoint, + QPointF, + QRect, + QRectF, + QSize, + QSizeF, + Qt, +) +from qtpy.QtGui import ( + QBrush, + QPainter, + QPen, + QPixmap, + QPolygonF, + QTransform, +) +from qtpy.QtSvg import QSvgRenderer + +from qwt.graphic import QwtGraphic + + +class QwtTriangle(object): + # enum Type + Left, Right, Up, Down = list(range(4)) + + +def qwtPathGraphic(path, pen, brush): + graphic = QwtGraphic() + graphic.setRenderHint(QwtGraphic.RenderPensUnscaled) + painter = QPainter(graphic) + painter.setPen(pen) + painter.setBrush(brush) + painter.drawPath(path) + painter.end() + return graphic + + +def qwtScaleBoundingRect(graphic, size): + scaledSize = QSize(size) + if scaledSize.isEmpty(): + scaledSize = graphic.defaultSize() + sz = graphic.controlPointRect().size() + sx = 1.0 + if sz.width() > 0.0: + sx = scaledSize.width() / sz.width() + sy = 1.0 + if sz.height() > 0.0: + sy = scaledSize.height() / sz.height() + return graphic.scaledBoundingRect(sx, sy) + + +def qwtDrawPixmapSymbols(painter, points, symbol): + size = symbol.size() + if size.isEmpty(): + size = symbol.pixmap().size() + transform = QTransform(painter.transform()) + if transform.isScaling(): + r = QRect(0, 0, size.width(), size.height()) + size = transform.mapRect(r).size() + pm = QPixmap(symbol.pixmap()) + if pm.size() != size: + pm = pm.scaled(size) + pinPoint = QPointF(0.5 * size.width(), 0.5 * size.height()) + if symbol.isPinPointEnabled(): + pinPoint = symbol.pinPoint() + painter.resetTransform() + for pos in points: + pos = QPointF(transform.map(pos)) - pinPoint + painter.drawPixmap(QRect(pos.toPoint(), pm.size()), pm) + + +def qwtDrawSvgSymbols(painter, points, renderer, symbol): + if renderer is None or not renderer.isValid(): + return + viewBox = QRectF(renderer.viewBoxF()) + if viewBox.isEmpty(): + return + sz = QSizeF(symbol.size()) + if not sz.isValid(): + sz = viewBox.size() + sx = sz.width() / viewBox.width() + sy = sz.height() / viewBox.height() + pinPoint = QPointF(viewBox.center()) + if symbol.isPinPointEnabled(): + pinPoint = symbol.pinPoint() + dx = sx * (pinPoint.x() - viewBox.left()) + dy = sy * (pinPoint.y() - viewBox.top()) + for pos in points: + x = pos.x() - dx + y = pos.y() - dy + renderer.render(painter, QRectF(x, y, sz.width(), sz.height())) + + +def qwtDrawGraphicSymbols(painter, points, graphic, symbol): + pointRect = QRectF(graphic.controlPointRect()) + if pointRect.isEmpty(): + return + sx = 1.0 + sy = 1.0 + sz = symbol.size() + if sz.isValid(): + sx = sz.width() / pointRect.width() + sy = sz.height() / pointRect.height() + pinPoint = QPointF(pointRect.center()) + if symbol.isPinPointEnabled(): + pinPoint = symbol.pinPoint() + transform = QTransform(painter.transform()) + for pos in points: + tr = QTransform(transform) + tr.translate(pos.x(), pos.y()) + tr.scale(sx, sy) + tr.translate(-pinPoint.x(), -pinPoint.y()) + painter.setTransform(tr) + graphic.render(painter) + painter.setTransform(transform) + + +def qwtDrawEllipseSymbols(painter, points, symbol): + painter.setBrush(symbol.brush()) + painter.setPen(symbol.pen()) + size = symbol.size() + sw = size.width() + sh = size.height() + sw2 = 0.5 * size.width() + sh2 = 0.5 * size.height() + for pos in points: + x = pos.x() + y = pos.y() + r = QRectF(x - sw2, y - sh2, sw, sh) + painter.drawEllipse(r) + + +def qwtDrawRectSymbols(painter, points, symbol): + size = symbol.size() + pen = QPen(symbol.pen()) + pen.setJoinStyle(Qt.MiterJoin) + painter.setPen(pen) + painter.setBrush(symbol.brush()) + painter.setRenderHint(QPainter.Antialiasing, False) + sw = size.width() + sh = size.height() + sw2 = 0.5 * size.width() + sh2 = 0.5 * size.height() + for pos in points: + x = pos.x() + y = pos.y() + r = QRectF(x - sw2, y - sh2, sw, sh) + painter.drawRect(r) + + +def qwtDrawDiamondSymbols(painter, points, symbol): + size = symbol.size() + pen = QPen(symbol.pen()) + pen.setJoinStyle(Qt.MiterJoin) + painter.setPen(pen) + painter.setBrush(symbol.brush()) + for pos in points: + x1 = pos.x() - 0.5 * size.width() + y1 = pos.y() - 0.5 * size.height() + x2 = x1 + size.width() + y2 = y1 + size.height() + polygon = QPolygonF() + polygon.append(QPointF(pos.x(), y1)) + polygon.append(QPointF(x1, pos.y())) + polygon.append(QPointF(pos.x(), y2)) + polygon.append(QPointF(x2, pos.y())) + painter.drawPolygon(polygon) + + +def qwtDrawTriangleSymbols(painter, type, points, symbol): + size = symbol.size() + pen = QPen(symbol.pen()) + pen.setJoinStyle(Qt.MiterJoin) + painter.setPen(pen) + painter.setBrush(symbol.brush()) + sw2 = 0.5 * size.width() + sh2 = 0.5 * size.height() + for pos in points: + x = pos.x() + y = pos.y() + x1 = x - sw2 + x2 = x1 + size.width() + y1 = y - sh2 + y2 = y1 + size.height() + if type == QwtTriangle.Left: + triangle = [QPointF(x2, y1), QPointF(x1, y), QPointF(x2, y2)] + elif type == QwtTriangle.Right: + triangle = [QPointF(x1, y1), QPointF(x2, y), QPointF(x1, y2)] + elif type == QwtTriangle.Up: + triangle = [QPointF(x1, y2), QPointF(x, y1), QPointF(x2, y2)] + elif type == QwtTriangle.Down: + triangle = [QPointF(x1, y1), QPointF(x, y2), QPointF(x2, y1)] + else: + raise TypeError("Unknown triangle type %s" % type) + painter.drawPolygon(QPolygonF(triangle)) + + +def qwtDrawLineSymbols(painter, orientations, points, symbol): + size = symbol.size() + pen = QPen(symbol.pen()) + if pen.width() > 1: + pen.setCapStyle(Qt.FlatCap) + painter.setPen(pen) + painter.setRenderHint(QPainter.Antialiasing, False) + sw = size.width() + sh = size.height() + sw2 = 0.5 * size.width() + sh2 = 0.5 * size.height() + for pos in points: + if orientations & Qt.Horizontal: + x = round(pos.x()) - sw2 + y = round(pos.y()) + painter.drawLine(QLineF(x, y, x + sw, y)) + if orientations & Qt.Vertical: + x = round(pos.x()) + y = round(pos.y()) - sh2 + painter.drawLine(QLineF(x, y, x, y + sh)) + + +def qwtDrawXCrossSymbols(painter, points, symbol): + size = symbol.size() + pen = QPen(symbol.pen()) + if pen.width() > 1: + pen.setCapStyle(Qt.FlatCap) + painter.setPen(pen) + sw = size.width() + sh = size.height() + sw2 = 0.5 * size.width() + sh2 = 0.5 * size.height() + for pos in points: + x1 = pos.x() - sw2 + x2 = x1 + sw + y1 = pos.y() - sh2 + y2 = y1 + sh + painter.drawLine(QLineF(x1, y1, x2, y2)) + painter.drawLine(QLineF(x2, y1, x1, y2)) + + +def qwtDrawStar1Symbols(painter, points, symbol): + size = symbol.size() + painter.setPen(symbol.pen()) + sqrt1_2 = math.sqrt(0.5) + r = QRectF(0, 0, size.width(), size.height()) + for pos in points: + r.moveCenter(pos) + c = QPointF(r.center()) + d1 = r.width() / 2.0 * (1.0 - sqrt1_2) + painter.drawLine( + QLineF(r.left() + d1, r.top() + d1, r.right() - d1, r.bottom() - d1) + ) + painter.drawLine( + QLineF(r.left() + d1, r.bottom() - d1, r.right() - d1, r.top() + d1) + ) + painter.drawLine(QLineF(c.x(), r.top(), c.x(), r.bottom())) + painter.drawLine(QLineF(r.left(), c.y(), r.right(), c.y())) + + +def qwtDrawStar2Symbols(painter, points, symbol): + pen = QPen(symbol.pen()) + if pen.width() > 1: + pen.setCapStyle(Qt.FlatCap) + pen.setJoinStyle(Qt.MiterJoin) + painter.setPen(pen) + painter.setBrush(symbol.brush()) + cos30 = math.cos(30 * math.pi / 180.0) + dy = 0.25 * symbol.size().height() + dx = 0.5 * symbol.size().width() * cos30 / 3.0 + for pos in points: + x = pos.x() + y = pos.y() + x1 = x - 3 * dx + y1 = y - 2 * dy + x2 = x1 + 1 * dx + x3 = x1 + 2 * dx + x4 = x1 + 3 * dx + x5 = x1 + 4 * dx + x6 = x1 + 5 * dx + x7 = x1 + 6 * dx + y2 = y1 + 1 * dy + y3 = y1 + 2 * dy + y4 = y1 + 3 * dy + y5 = y1 + 4 * dy + star = [ + QPointF(x4, y1), + QPointF(x5, y2), + QPointF(x7, y2), + QPointF(x6, y3), + QPointF(x7, y4), + QPointF(x5, y4), + QPointF(x4, y5), + QPointF(x3, y4), + QPointF(x1, y4), + QPointF(x2, y3), + QPointF(x1, y2), + QPointF(x3, y2), + ] + painter.drawPolygon(QPolygonF(star)) + + +def qwtDrawHexagonSymbols(painter, points, symbol): + painter.setBrush(symbol.brush()) + painter.setPen(symbol.pen()) + cos30 = math.cos(30 * math.pi / 180.0) + dx = 0.5 * (symbol.size().width() - cos30) + dy = 0.25 * symbol.size().height() + for pos in points: + x = pos.x() + y = pos.y() + x1 = x - dx + y1 = y - 2 * dy + x2 = x1 + 1 * dx + x3 = x1 + 2 * dx + y2 = y1 + 1 * dy + y3 = y1 + 3 * dy + y4 = y1 + 4 * dy + hexa = [ + QPointF(x2, y1), + QPointF(x3, y2), + QPointF(x3, y3), + QPointF(x2, y4), + QPointF(x1, y3), + QPointF(x1, y2), + ] + painter.drawPolygon(QPolygonF(hexa)) + + +class QwtSymbol_PrivateData(QObject): + def __init__(self, st, br, pn, sz): + QObject.__init__(self) + self.style = st + self.size = sz + self.brush = br + self.pen = pn + self.isPinPointEnabled = False + self.pinPoint = None + + class Path(object): + def __init__(self): + self.path = None # QPainterPath() + self.graphic = QwtGraphic() + + self.path = Path() + + self.pixmap = None + + class Graphic(object): + def __init__(self): + self.graphic = QwtGraphic() + + self.graphic = Graphic() + + class SVG(object): + def __init__(self): + self.renderer = QSvgRenderer() + + self.svg = SVG() + + class PaintCache(object): + def __init__(self): + self.policy = 0 + self.pixmap = None # QPixmap() + + self.cache = PaintCache() + + +class QwtSymbol(object): + """ + A class for drawing symbols + + Symbol styles: + + * `QwtSymbol.NoSymbol`: No Style. The symbol cannot be drawn. + * `QwtSymbol.Ellipse`: Ellipse or circle + * `QwtSymbol.Rect`: Rectangle + * `QwtSymbol.Diamond`: Diamond + * `QwtSymbol.Triangle`: Triangle pointing upwards + * `QwtSymbol.DTriangle`: Triangle pointing downwards + * `QwtSymbol.UTriangle`: Triangle pointing upwards + * `QwtSymbol.LTriangle`: Triangle pointing left + * `QwtSymbol.RTriangle`: Triangle pointing right + * `QwtSymbol.Cross`: Cross (+) + * `QwtSymbol.XCross`: Diagonal cross (X) + * `QwtSymbol.HLine`: Horizontal line + * `QwtSymbol.VLine`: Vertical line + * `QwtSymbol.Star1`: X combined with + + * `QwtSymbol.Star2`: Six-pointed star + * `QwtSymbol.Hexagon`: Hexagon + * `QwtSymbol.Path`: The symbol is represented by a painter path, where + the origin (0, 0) of the path coordinate system is mapped to the + position of the symbol + + ..seealso:: + + :py:meth:`setPath()`, :py:meth:`path()` + * `QwtSymbol.Pixmap`: The symbol is represented by a pixmap. + The pixmap is centered or aligned to its pin point. + + ..seealso:: + + :py:meth:`setPinPoint()` + * `QwtSymbol.Graphic`: The symbol is represented by a graphic. + The graphic is centered or aligned to its pin point. + + ..seealso:: + + :py:meth:`setPinPoint()` + * `QwtSymbol.SvgDocument`: The symbol is represented by a SVG graphic. + The graphic is centered or aligned to its pin point. + + ..seealso:: + + :py:meth:`setPinPoint()` + * `QwtSymbol.UserStyle`: Styles >= `QwtSymbol.UserStyle` are reserved + for derived classes of `QwtSymbol` that overload `drawSymbols()` with + additional application specific symbol types. + + Cache policies: + + Depending on the render engine and the complexity of the + symbol shape it might be faster to render the symbol + to a pixmap and to paint this pixmap. + + F.e. the raster paint engine is a pure software renderer + where in cache mode a draw operation usually ends in + raster operation with the the backing store, that are usually + faster, than the algorithms for rendering polygons. + But the opposite can be expected for graphic pipelines + that can make use of hardware acceleration. + + The default setting is AutoCache + + ..seealso:: + + :py:meth:`setCachePolicy()`, :py:meth:`cachePolicy()` + + .. note:: + + The policy has no effect, when the symbol is painted + to a vector graphics format (PDF, SVG). + + .. warning:: + + Since Qt 4.8 raster is the default backend on X11 + + Valid cache policies: + + * `QwtSymbol.NoCache`: Don't use a pixmap cache + * `QwtSymbol.Cache`: Always use a pixmap cache + * `QwtSymbol.AutoCache`: Use a cache when the symbol is rendered + with the software renderer (`QPaintEngine.Raster`) + + .. py:class:: QwtSymbol([style=QwtSymbol.NoSymbol]) + + The symbol is constructed with gray interior, + black outline with zero width, no size and style 'NoSymbol'. + + :param int style: Symbol Style + + .. py:class:: QwtSymbol(style, brush, pen, size) + :noindex: + + :param int style: Symbol Style + :param QBrush brush: Brush to fill the interior + :param QPen pen: Outline pen + :param QSize size: Size + + .. py:class:: QwtSymbol(path, brush, pen) + :noindex: + + :param QPainterPath path: Painter path + :param QBrush brush: Brush to fill the interior + :param QPen pen: Outline pen + + .. seealso:: + + :py:meth:`setPath()`, :py:meth:`setBrush()`, + :py:meth:`setPen()`, :py:meth:`setSize()` + """ + + # enum Style + Style = int + NoSymbol = -1 + ( + Ellipse, + Rect, + Diamond, + Triangle, + DTriangle, + UTriangle, + LTriangle, + RTriangle, + Cross, + XCross, + HLine, + VLine, + Star1, + Star2, + Hexagon, + Path, + Pixmap, + Graphic, + SvgDocument, + ) = list(range(19)) + UserStyle = 1000 + + # enum CachePolicy + NoCache, Cache, AutoCache = list(range(3)) + + def __init__(self, *args): + if len(args) in (0, 1): + if args: + (style,) = args + else: + style = QwtSymbol.NoSymbol + self.__data = QwtSymbol_PrivateData( + style, QBrush(Qt.gray), QPen(Qt.black, 0), QSize() + ) + elif len(args) == 4: + style, brush, pen, size = args + self.__data = QwtSymbol_PrivateData(style, brush, pen, size) + elif len(args) == 3: + path, brush, pen = args + self.__data = QwtSymbol_PrivateData(QwtSymbol.Path, brush, pen, QSize()) + self.setPath(path) + else: + raise TypeError( + "%s() takes 1, 3, or 4 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + + @classmethod + def make( + cls, + style=None, + brush=None, + pen=None, + size=None, + path=None, + pixmap=None, + graphic=None, + svgdocument=None, + pinpoint=None, + ): + """ + Create and setup a new `QwtSymbol` object (convenience function). + + :param style: Symbol Style + :type style: int or None + :param brush: Brush to fill the interior + :type brush: QBrush or None + :param pen: Outline pen + :type pen: QPen or None + :param size: Size + :type size: QSize or None + :param path: Painter path + :type path: QPainterPath or None + :param path: Painter path + :type path: QPainterPath or None + :param pixmap: Pixmap as symbol + :type pixmap: QPixmap or None + :param graphic: Graphic + :type graphic: qwt.graphic.QwtGraphic or None + :param svgdocument: SVG icon as symbol + + .. seealso:: + + :py:meth:`setPixmap()`, :py:meth:`setGraphic()`, :py:meth:`setPath()` + """ + style = QwtSymbol.NoSymbol if style is None else style + brush = QBrush(Qt.gray) if brush is None else QBrush(brush) + pen = QPen(Qt.black, 0) if pen is None else QPen(pen) + size = QSize() if size is None else size + if not isinstance(size, QSize): + if isinstance(size, tuple) and len(size) == 2: + size = QSize(size[0], size[1]) + else: + raise TypeError("Invalid size %r" % size) + item = cls(style, brush, pen, size) + if path is not None: + item.setPath(path) + elif pixmap is not None: + item.setPixmap(pixmap) + elif graphic is not None: + item.setGraphic(graphic) + elif svgdocument is not None: + item.setSvgDocument(svgdocument) + if pinpoint is not None: + item.setPinPoint(pinpoint) + return item + + def setCachePolicy(self, policy): + """ + Change the cache policy + + The default policy is AutoCache + + :param int policy: Cache policy + + .. seealso:: + + :py:meth:`cachePolicy()` + """ + if self.__data.cache.policy != policy: + self.__data.cache.policy = policy + self.invalidateCache() + + def cachePolicy(self): + """ + :return: Cache policy + + .. seealso:: + + :py:meth:`setCachePolicy()` + """ + return self.__data.cache.policy + + def setPath(self, path): + """ + Set a painter path as symbol + + The symbol is represented by a painter path, where the + origin (0, 0) of the path coordinate system is mapped to + the position of the symbol. + + When the symbol has valid size the painter path gets scaled + to fit into the size. Otherwise the symbol size depends on + the bounding rectangle of the path. + + The following code defines a symbol drawing an arrow:: + + from qtpy.QtGui import QApplication, QPen, QPainterPath, QTransform + from qtpy.QtCore import Qt, QPointF + from qwt import QwtPlot, QwtPlotCurve, QwtSymbol + import numpy as np + + app = QApplication([]) + + # --- Construct custom symbol --- + + path = QPainterPath() + path.moveTo(0, 8) + path.lineTo(0, 5) + path.lineTo(-3, 5) + path.lineTo(0, 0) + path.lineTo(3, 5) + path.lineTo(0, 5) + + transform = QTransform() + transform.rotate(-30.0) + path = transform.map(path) + + pen = QPen(Qt.black, 2 ); + pen.setJoinStyle(Qt.MiterJoin) + + symbol = QwtSymbol() + symbol.setPen(pen) + symbol.setBrush(Qt.red) + symbol.setPath(path) + symbol.setPinPoint(QPointF(0., 0.)) + symbol.setSize(10, 14) + + # --- Test it within a simple plot --- + + curve = QwtPlotCurve() + curve_pen = QPen(Qt.blue) + curve_pen.setStyle(Qt.DotLine) + curve.setPen(curve_pen) + curve.setSymbol(symbol) + x = np.linspace(0, 10, 10) + curve.setData(x, np.sin(x)) + + plot = QwtPlot() + curve.attach(plot) + plot.resize(600, 300) + plot.replot() + plot.show() + + app.exec_() + + .. image:: /_static/symbol_path_example.png + + :param QPainterPath path: Painter path + + .. seealso:: + + :py:meth:`path()`, :py:meth:`setSize()` + """ + self.__data.style = QwtSymbol.Path + self.__data.path.path = path + self.__data.path.graphic.reset() + + def path(self): + """ + :return: Painter path for displaying the symbol + + .. seealso:: + + :py:meth:`setPath()` + """ + return self.__data.path.path + + def setPixmap(self, pixmap): + """ + Set a pixmap as symbol + + :param QPixmap pixmap: Pixmap + + .. seealso:: + + :py:meth:`pixmap()`, :py:meth:`setGraphic()` + + .. note:: + + The `style()` is set to `QwtSymbol.Pixmap` + + .. note:: + + `brush()` and `pen()` have no effect + """ + self.__data.style = QwtSymbol.Pixmap + self.__data.pixmap = pixmap + + def pixmap(self): + """ + :return: Assigned pixmap + + .. seealso:: + + :py:meth:`setPixmap()` + """ + if self.__data.pixmap is None: + return QPixmap() + return self.__data.pixmap + + def setGraphic(self, graphic): + """ + Set a graphic as symbol + + :param qwt.graphic.QwtGraphic graphic: Graphic + + .. seealso:: + + :py:meth:`graphic()`, :py:meth:`setPixmap()` + + .. note:: + + The `style()` is set to `QwtSymbol.Graphic` + + .. note:: + + `brush()` and `pen()` have no effect + """ + self.__data.style = QwtSymbol.Graphic + self.__data.graphic.graphic = graphic + + def graphic(self): + """ + :return: Assigned graphic + + .. seealso:: + + :py:meth:`setGraphic()` + """ + return self.__data.graphic.graphic + + def setSvgDocument(self, svgDocument): + """ + Set a SVG icon as symbol + + :param svgDocument: SVG icon + + .. seealso:: + + :py:meth:`setGraphic()`, :py:meth:`setPixmap()` + + .. note:: + + The `style()` is set to `QwtSymbol.SvgDocument` + + .. note:: + + `brush()` and `pen()` have no effect + """ + self.__data.style = QwtSymbol.SvgDocument + if self.__data.svg.renderer is None: + self.__data.svg.renderer = QSvgRenderer() + self.__data.svg.renderer.load(svgDocument) + + def setSize(self, *args): + """ + Specify the symbol's size + + .. py:method:: setSize(width, [height=-1]) + :noindex: + + :param int width: Width + :param int height: Height + + .. py:method:: setSize(size) + :noindex: + + :param QSize size: Size + + .. seealso:: + + :py:meth:`size()` + """ + if len(args) == 2: + width, height = args + if width >= 0 and height < 0: + height = width + self.setSize(QSize(width, height)) + elif len(args) == 1: + if isinstance(args[0], QSize): + (size,) = args + if size.isValid() and size != self.__data.size: + self.__data.size = size + self.invalidateCache() + else: + (width,) = args + self.setSize(width, -1) + else: + raise TypeError( + "%s().setSize() takes 1 or 2 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + + def size(self): + """ + :return: Size + + .. seealso:: + + :py:meth:`setSize()` + """ + return self.__data.size + + def setBrush(self, brush): + """ + Assign a brush + + The brush is used to draw the interior of the symbol. + + :param QBrush brush: Brush + + .. seealso:: + + :py:meth:`brush()` + """ + if brush != self.__data.brush: + self.__data.brush = brush + self.invalidateCache() + if self.__data.style == QwtSymbol.Path: + self.__data.path.graphic.reset() + + def brush(self): + """ + :return: Brush + + .. seealso:: + + :py:meth:`setBrush()` + """ + return self.__data.brush + + def setPen(self, *args): + """ + Build and/or assign a pen, depending on the arguments. + + .. py:method:: setPen(color, width, style) + :noindex: + + Build and assign a pen + + In Qt5 the default pen width is 1.0 ( 0.0 in Qt4 ) what makes it + non cosmetic (see `QPen.isCosmetic()`). This method signature has + been introduced to hide this incompatibility. + + :param QColor color: Pen color + :param float width: Pen width + :param Qt.PenStyle style: Pen style + + .. py:method:: setPen(pen) + :noindex: + + Assign a pen + + :param QPen pen: New pen + + .. seealso:: + + :py:meth:`pen()`, :py:meth:`brush()` + """ + if len(args) == 3: + color, width, style = args + self.setPen(QPen(color, width, style)) + elif len(args) == 1: + (pen,) = args + if pen != self.__data.pen: + self.__data.pen = pen + self.invalidateCache() + if self.__data.style == QwtSymbol.Path: + self.__data.path.graphic.reset() + else: + raise TypeError( + "%s().setPen() takes 1 or 3 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) + + def pen(self): + """ + :return: Pen + + .. seealso:: + + :py:meth:`setPen()`, :py:meth:`brush()` + """ + return self.__data.pen + + def setColor(self, color): + """ + Set the color of the symbol + + Change the color of the brush for symbol types with a filled area. + For all other symbol types the color will be assigned to the pen. + + :param QColor color: Color + + .. seealso:: + + :py:meth:`setPen()`, :py:meth:`setBrush()`, + :py:meth:`brush()`, :py:meth:`pen()` + """ + if self.__data.style in ( + QwtSymbol.Ellipse, + QwtSymbol.Rect, + QwtSymbol.Diamond, + QwtSymbol.Triangle, + QwtSymbol.UTriangle, + QwtSymbol.DTriangle, + QwtSymbol.RTriangle, + QwtSymbol.LTriangle, + QwtSymbol.Star2, + QwtSymbol.Hexagon, + ): + if self.__data.brush.color() != color: + self.__data.brush.setColor(color) + self.invalidateCache() + elif self.__data.style in ( + QwtSymbol.Cross, + QwtSymbol.XCross, + QwtSymbol.HLine, + QwtSymbol.VLine, + QwtSymbol.Star1, + ): + if self.__data.pen.color() != color: + self.__data.pen.setColor(color) + self.invalidateCache() + else: + if self.__data.brush.color() != color or self.__data.pen.color() != color: + self.invalidateCache() + self.__data.brush.setColor(color) + self.__data.pen.setColor(color) + + def setPinPoint(self, pos, enable=True): + """ + Set and enable a pin point + + The position of a complex symbol is not always aligned to its center + ( f.e an arrow, where the peak points to a position ). The pin point + defines the position inside of a Pixmap, Graphic, SvgDocument + or PainterPath symbol where the represented point has to + be aligned to. + + :param QPointF pos: Position + :enable bool enable: En/Disable the pin point alignment + + .. seealso:: + + :py:meth:`pinPoint()`, :py:meth:`setPinPointEnabled()` + """ + if self.__data.pinPoint != pos: + self.__data.pinPoint = pos + if self.__data.isPinPointEnabled: + self.invalidateCache() + self.setPinPointEnabled(enable) + + def pinPoint(self): + """ + :return: Pin point + + .. seealso:: + + :py:meth:`setPinPoint()`, :py:meth:`setPinPointEnabled()` + """ + return self.__data.pinPoint + + def setPinPointEnabled(self, on): + """ + En/Disable the pin point alignment + + :param bool on: Enabled, when on is true + + .. seealso:: + + :py:meth:`setPinPoint()`, :py:meth:`isPinPointEnabled()` + """ + if self.__data.isPinPointEnabled != on: + self.__data.isPinPointEnabled = on + self.invalidateCache() + + def isPinPointEnabled(self): + """ + :return: True, when the pin point translation is enabled + + .. seealso:: + + :py:meth:`setPinPoint()`, :py:meth:`setPinPointEnabled()` + """ + return self.__data.isPinPointEnabled + + def drawSymbols(self, painter, points): + """ + Render an array of symbols + + Painting several symbols is more effective than drawing symbols + one by one, as a couple of layout calculations and setting of pen/brush + can be done once for the complete array. + + :param QPainter painter: Painter + :param QPolygonF points: Positions of the symbols in screen coordinates + """ + painter.save() + self.renderSymbols(painter, points) + painter.restore() + + def drawSymbol(self, painter, point_or_rect): + """ + Draw the symbol into a rectangle + + The symbol is painted centered and scaled into the target rectangle. + It is always painted uncached and the pin point is ignored. + + This method is primarily intended for drawing a symbol to the legend. + + :param QPainter painter: Painter + :param point_or_rect: Position or target rectangle of the symbol in screen coordinates + :type point_or_rect: QPointF or QPoint or QRectF + """ + if isinstance(point_or_rect, (QPointF, QPoint)): + # drawSymbol( QPainter *, const QPointF & ) + self.drawSymbols(painter, [point_or_rect]) + return + # drawSymbol( QPainter *, const QRectF & ) + rect = point_or_rect + assert isinstance(rect, QRectF) + if self.__data.style == QwtSymbol.NoSymbol: + return + if self.__data.style == QwtSymbol.Graphic: + self.__data.graphic.graphic.render(painter, rect, Qt.KeepAspectRatio) + elif self.__data.style == QwtSymbol.Path: + if self.__data.path.graphic.isNull(): + self.__data.path.graphic = qwtPathGraphic( + self.__data.path.path, self.__data.pen, self.__data.brush + ) + self.__data.path.graphic.render(painter, rect, Qt.KeepAspectRatio) + return + elif self.__data.style == QwtSymbol.SvgDocument: + if self.__data.svg.renderer is not None: + scaledRect = QRectF() + sz = QSizeF(self.__data.svg.renderer.viewBoxF().size()) + if not sz.isEmpty(): + sz.scale(rect.size(), Qt.KeepAspectRatio) + scaledRect.setSize(sz) + scaledRect.moveCenter(rect.center()) + else: + scaledRect = rect + self.__data.svg.renderer.render(painter, scaledRect) + else: + br = QRect(self.boundingRect()) + ratio = min([rect.width() / br.width(), rect.height() / br.height()]) + painter.save() + painter.translate(rect.center()) + painter.scale(ratio, ratio) + isPinPointEnabled = self.__data.isPinPointEnabled + self.__data.isPinPointEnabled = False + self.renderSymbols(painter, [QPointF()]) + self.__data.isPinPointEnabled = isPinPointEnabled + painter.restore() + + def renderSymbols(self, painter, points): + """ + Render the symbol to series of points + + :param QPainter painter: Painter + :param point_or_rect: Positions of the symbols + """ + if self.__data.style == QwtSymbol.Ellipse: + qwtDrawEllipseSymbols(painter, points, self) + elif self.__data.style == QwtSymbol.Rect: + qwtDrawRectSymbols(painter, points, self) + elif self.__data.style == QwtSymbol.Diamond: + qwtDrawDiamondSymbols(painter, points, self) + elif self.__data.style == QwtSymbol.Cross: + qwtDrawLineSymbols(painter, Qt.Horizontal | Qt.Vertical, points, self) + elif self.__data.style == QwtSymbol.XCross: + qwtDrawXCrossSymbols(painter, points, self) + elif self.__data.style in (QwtSymbol.Triangle, QwtSymbol.UTriangle): + qwtDrawTriangleSymbols(painter, QwtTriangle.Up, points, self) + elif self.__data.style == QwtSymbol.DTriangle: + qwtDrawTriangleSymbols(painter, QwtTriangle.Down, points, self) + elif self.__data.style == QwtSymbol.RTriangle: + qwtDrawTriangleSymbols(painter, QwtTriangle.Right, points, self) + elif self.__data.style == QwtSymbol.LTriangle: + qwtDrawTriangleSymbols(painter, QwtTriangle.Left, points, self) + elif self.__data.style == QwtSymbol.HLine: + qwtDrawLineSymbols(painter, Qt.Horizontal, points, self) + elif self.__data.style == QwtSymbol.VLine: + qwtDrawLineSymbols(painter, Qt.Vertical, points, self) + elif self.__data.style == QwtSymbol.Star1: + qwtDrawStar1Symbols(painter, points, self) + elif self.__data.style == QwtSymbol.Star2: + qwtDrawStar2Symbols(painter, points, self) + elif self.__data.style == QwtSymbol.Hexagon: + qwtDrawHexagonSymbols(painter, points, self) + elif self.__data.style == QwtSymbol.Path: + if self.__data.path.graphic.isNull(): + self.__data.path.graphic = qwtPathGraphic( + self.__data.path.path, self.__data.pen, self.__data.brush + ) + qwtDrawGraphicSymbols(painter, points, self.__data.path.graphic, self) + elif self.__data.style == QwtSymbol.Pixmap: + qwtDrawPixmapSymbols(painter, points, self) + elif self.__data.style == QwtSymbol.Graphic: + qwtDrawGraphicSymbols(painter, points, self.__data.graphic.graphic, self) + elif self.__data.style == QwtSymbol.SvgDocument: + qwtDrawSvgSymbols(painter, points, self.__data.svg.renderer, self) + + def boundingRect(self): + """ + Calculate the bounding rectangle for a symbol at position (0,0). + + :return: Bounding rectangle + """ + rect = QRectF() + pinPointTranslation = False + if self.__data.style in (QwtSymbol.Ellipse, QwtSymbol.Rect, QwtSymbol.Hexagon): + pw = 0.0 + if self.__data.pen.style() != Qt.NoPen: + pw = max([self.__data.pen.widthF(), 1.0]) + rect.setSize(QSizeF(self.__data.size) + QSizeF(pw, pw)) + rect.moveCenter(QPointF(0.0, 0.0)) + elif self.__data.style in ( + QwtSymbol.XCross, + QwtSymbol.Diamond, + QwtSymbol.Triangle, + QwtSymbol.UTriangle, + QwtSymbol.DTriangle, + QwtSymbol.RTriangle, + QwtSymbol.LTriangle, + QwtSymbol.Star1, + QwtSymbol.Star2, + ): + pw = 0.0 + if self.__data.pen.style() != Qt.NoPen: + pw = max([self.__data.pen.widthF(), 1.0]) + rect.setSize(QSizeF(self.__data.size) + QSizeF(2 * pw, 2 * pw)) + rect.moveCenter(QPointF(0.0, 0.0)) + elif self.__data.style == QwtSymbol.Path: + if self.__data.path.graphic.isNull(): + self.__data.path.graphic = qwtPathGraphic( + self.__data.path.path, self.__data.pen, self.__data.brush + ) + rect = qwtScaleBoundingRect(self.__data.path.graphic, self.__data.size) + pinPointTranslation = True + elif self.__data.style == QwtSymbol.Pixmap: + if self.__data.size.isEmpty(): + rect.setSize(QSizeF(self.pixmap().size())) + else: + rect.setSize(QSizeF(self.__data.size)) + pinPointTranslation = True + elif self.__data.style == QwtSymbol.Graphic: + rect = qwtScaleBoundingRect(self.__data.graphic.graphic, self.__data.size) + pinPointTranslation = True + elif self.__data.style == QwtSymbol.SvgDocument: + if self.__data.svg.renderer is not None: + rect = self.__data.svg.renderer.viewBoxF() + if self.__data.size.isValid() and not rect.isEmpty(): + sz = QSizeF(rect.size()) + sx = self.__data.size.width() / sz.width() + sy = self.__data.size.height() / sz.height() + transform = QTransform() + transform.scale(sx, sy) + rect = transform.mapRect(rect) + pinPointTranslation = True + else: + rect.setSize(QSizeF(self.__data.size)) + rect.moveCenter(QPointF(0.0, 0.0)) + if pinPointTranslation: + pinPoint = QPointF(0.0, 0.0) + if self.__data.isPinPointEnabled: + pinPoint = rect.center() - self.__data.pinPoint + rect.moveCenter(pinPoint) + r = QRect() + r.setLeft(math.floor(rect.left())) + r.setTop(math.floor(rect.top())) + r.setRight(math.floor(rect.right())) + r.setBottom(math.floor(rect.bottom())) + if self.__data.style != QwtSymbol.Pixmap: + r.adjust(-1, -1, 1, 1) + return r + + def invalidateCache(self): + """ + Invalidate the cached symbol pixmap + + The symbol invalidates its cache, whenever an attribute is changed + that has an effect ob how to display a symbol. In case of derived + classes with individual styles (>= `QwtSymbol.UserStyle`) it + might be necessary to call invalidateCache() for attributes + that are relevant for this style. + + .. seealso:: + + :py:meth:`setCachePolicy()`, :py:meth:`drawSymbols()` + """ + if self.__data.cache.pixmap is not None: + self.__data.cache.pixmap = None + + def setStyle(self, style): + """ + Specify the symbol style + + :param int style: Style + + .. seealso:: + + :py:meth:`style()` + """ + if self.__data.style != style: + self.__data.style = style + self.invalidateCache() + + def style(self): + """ + :return: Current symbol style + + .. seealso:: + + :py:meth:`setStyle()` + """ + return self.__data.style diff --git a/qwt/tests/__init__.py b/qwt/tests/__init__.py new file mode 100644 index 0000000..6d6348b --- /dev/null +++ b/qwt/tests/__init__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +""" +PythonQwt test package +====================== +""" + +from qtpy import QtCore as QC +from qtpy import QtWidgets as QW + +from qwt.tests.utils import ( + QT_API, + TestEnvironment, + TestLauncher, + run_all_tests, + take_screenshot, +) + + +def run(wait=True): + """Run PythonQwt tests or test launcher""" + app = QW.QApplication([]) + launcher = TestLauncher() + launcher.show() + test_env = TestEnvironment() + if test_env.screenshots: + print("Running PythonQwt tests and taking screenshots automatically:") + QC.QTimer.singleShot(100, lambda: take_screenshot(launcher)) + elif test_env.unattended: + print("Running PythonQwt tests in unattended mode:") + QC.QTimer.singleShot(0, QW.QApplication.instance().quit) + if QT_API == "pyside6": + app.exec() + else: + app.exec_() + launcher.close() + if test_env.unattended: + run_all_tests(wait=wait) + + +if __name__ == "__main__": + run() diff --git a/qwt/tests/comparative_benchmarks.py b/qwt/tests/comparative_benchmarks.py new file mode 100644 index 0000000..22c0bca --- /dev/null +++ b/qwt/tests/comparative_benchmarks.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +""" +PyQwt5 vs. PythonQwt +==================== +""" + +import os +import os.path as osp +import subprocess +import sys +import time + + +def get_winpython_exe(rootpath, pymajor=None, pyminor=None): + """Return WinPython exe list from rootpath""" + exelist = [] + for name1 in os.listdir(rootpath): + winroot = osp.join(rootpath, name1) + if osp.isdir(winroot): + for name2 in os.listdir(winroot): + pypath = osp.join(winroot, name2, "python.exe") + if osp.isfile(pypath): + pymaj, pymin = name2[len("python-") :].split(".")[:2] + if pymajor is None or pymajor == int(pymaj): + if pyminor is None or int(pymin) >= pyminor: + exelist.append(pypath) + return exelist + + +def run_script(filename, args=None, wait=True, executable=None): + """Run Python script""" + os.environ["PYTHONPATH"] = os.pathsep.join(sys.path) + if executable is None: + executable = sys.executable + command = [executable, '"' + filename + '"'] + if args is not None: + command.append(args) + print(" ".join(command)) + proc = subprocess.Popen(" ".join(command), shell=True) + if wait: + proc.wait() + + +def main(): + for name in ( + "curvebenchmark1.py", + "curvebenchmark2.py", + ): + for executable in get_winpython_exe(r"C:\Apps", pymajor=3, pyminor=6): + filename = osp.join(osp.dirname(osp.abspath(__file__)), name) + run_script(filename, wait=False, executable=executable) + time.sleep(4) + + +if __name__ == "__main__": + # print(get_winpython_exe(r"C:\Apps", pymajor=3)) + main() diff --git a/qwt/tests/conftest.py b/qwt/tests/conftest.py new file mode 100644 index 0000000..e60b9cd --- /dev/null +++ b/qwt/tests/conftest.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +"""pytest configuration for PythonQwt package tests.""" + +import os + +import qtpy + +import qwt +from qwt.tests.utils import TestEnvironment + +# Set the unattended environment variable to 1 to avoid any user interaction +os.environ[TestEnvironment.UNATTENDED_ENV] = "1" + + +def pytest_addoption(parser): + """Add custom command line options to pytest.""" + # See this StackOverflow answer for more information: https://t.ly/9anqz + parser.addoption( + "--repeat", action="store", help="Number of times to repeat each test" + ) + parser.addoption( + "--show-windows", + action="store_true", + default=False, + help="Display Qt windows during tests (disables QT_QPA_PLATFORM=offscreen)", + ) + + +def pytest_configure(config): + """Configure pytest based on command line options.""" + if config.option.durations is None: + config.option.durations = 10 # Default to showing 10 slowest tests + if not config.getoption("--show-windows"): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +def pytest_report_header(config): + """Add additional information to the pytest report header.""" + qtbindings_version = qtpy.PYSIDE_VERSION + if qtbindings_version is None: + qtbindings_version = qtpy.PYQT_VERSION + return [ + f"PythonQwt {qwt.__version__} [closest Qwt version: {qwt.QWT_VERSION_STR}]", + f"{qtpy.API_NAME} {qtbindings_version} [Qt version: {qtpy.QT_VERSION}]", + ] + + +def pytest_generate_tests(metafunc): + """Generate tests for the given metafunc.""" + # See this StackOverflow answer for more information: https://t.ly/9anqz + if metafunc.config.option.repeat is not None: + count = int(metafunc.config.option.repeat) + + # We're going to duplicate these tests by parametrizing them, + # which requires that each test has a fixture to accept the parameter. + # We can add a new fixture like so: + metafunc.fixturenames.append("tmp_ct") + + # Now we parametrize. This is what happens when we do e.g., + # @pytest.mark.parametrize('tmp_ct', range(count)) + # def test_foo(): pass + metafunc.parametrize("tmp_ct", range(count)) diff --git a/qwt/tests/data/PythonQwt.svg b/qwt/tests/data/PythonQwt.svg new file mode 100644 index 0000000..92bbe2c --- /dev/null +++ b/qwt/tests/data/PythonQwt.svg @@ -0,0 +1,484 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qwt/tests/data/bodedemo.png b/qwt/tests/data/bodedemo.png new file mode 100644 index 0000000..e3ca87e Binary files /dev/null and b/qwt/tests/data/bodedemo.png differ diff --git a/qwt/tests/data/cartesian.png b/qwt/tests/data/cartesian.png new file mode 100644 index 0000000..449ac14 Binary files /dev/null and b/qwt/tests/data/cartesian.png differ diff --git a/qwt/tests/data/cpudemo.png b/qwt/tests/data/cpudemo.png new file mode 100644 index 0000000..5f5660b Binary files /dev/null and b/qwt/tests/data/cpudemo.png differ diff --git a/qwt/tests/data/curvebenchmark1.png b/qwt/tests/data/curvebenchmark1.png new file mode 100644 index 0000000..261dfe9 Binary files /dev/null and b/qwt/tests/data/curvebenchmark1.png differ diff --git a/qwt/tests/data/curvebenchmark2.png b/qwt/tests/data/curvebenchmark2.png new file mode 100644 index 0000000..162c27f Binary files /dev/null and b/qwt/tests/data/curvebenchmark2.png differ diff --git a/qwt/tests/data/curvedemo1.png b/qwt/tests/data/curvedemo1.png new file mode 100644 index 0000000..8cc3e8e Binary files /dev/null and b/qwt/tests/data/curvedemo1.png differ diff --git a/qwt/tests/data/curvedemo2.png b/qwt/tests/data/curvedemo2.png new file mode 100644 index 0000000..b060f67 Binary files /dev/null and b/qwt/tests/data/curvedemo2.png differ diff --git a/qwt/tests/data/data.png b/qwt/tests/data/data.png new file mode 100644 index 0000000..f7ae40c Binary files /dev/null and b/qwt/tests/data/data.png differ diff --git a/qwt/tests/data/errorbar.png b/qwt/tests/data/errorbar.png new file mode 100644 index 0000000..bea587f Binary files /dev/null and b/qwt/tests/data/errorbar.png differ diff --git a/qwt/tests/data/eventfilter.png b/qwt/tests/data/eventfilter.png new file mode 100644 index 0000000..5dbcc1b Binary files /dev/null and b/qwt/tests/data/eventfilter.png differ diff --git a/qwt/tests/data/image.png b/qwt/tests/data/image.png new file mode 100644 index 0000000..d211916 Binary files /dev/null and b/qwt/tests/data/image.png differ diff --git a/qwt/tests/data/loadtest.png b/qwt/tests/data/loadtest.png new file mode 100644 index 0000000..8b4b8e5 Binary files /dev/null and b/qwt/tests/data/loadtest.png differ diff --git a/qwt/tests/data/logcurve.png b/qwt/tests/data/logcurve.png new file mode 100644 index 0000000..e81de61 Binary files /dev/null and b/qwt/tests/data/logcurve.png differ diff --git a/qwt/tests/data/mapdemo.png b/qwt/tests/data/mapdemo.png new file mode 100644 index 0000000..623a93e Binary files /dev/null and b/qwt/tests/data/mapdemo.png differ diff --git a/qwt/tests/data/multidemo.png b/qwt/tests/data/multidemo.png new file mode 100644 index 0000000..7f7b8a7 Binary files /dev/null and b/qwt/tests/data/multidemo.png differ diff --git a/qwt/tests/data/simple.png b/qwt/tests/data/simple.png new file mode 100644 index 0000000..2aa8593 Binary files /dev/null and b/qwt/tests/data/simple.png differ diff --git a/qwt/tests/data/stylesheet.png b/qwt/tests/data/stylesheet.png new file mode 100644 index 0000000..576a43e Binary files /dev/null and b/qwt/tests/data/stylesheet.png differ diff --git a/qwt/tests/data/symbol.svg b/qwt/tests/data/symbol.svg new file mode 100644 index 0000000..146b0be --- /dev/null +++ b/qwt/tests/data/symbol.svg @@ -0,0 +1,411 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/qwt/tests/data/symbols.png b/qwt/tests/data/symbols.png new file mode 100644 index 0000000..17cb695 Binary files /dev/null and b/qwt/tests/data/symbols.png differ diff --git a/qwt/tests/data/testlauncher.png b/qwt/tests/data/testlauncher.png new file mode 100644 index 0000000..df1bf76 Binary files /dev/null and b/qwt/tests/data/testlauncher.png differ diff --git a/qwt/tests/data/vertical.png b/qwt/tests/data/vertical.png new file mode 100644 index 0000000..21a981d Binary files /dev/null and b/qwt/tests/data/vertical.png differ diff --git a/qwt/tests/test_backingstore.py b/qwt/tests/test_backingstore.py new file mode 100644 index 0000000..4a0467f --- /dev/null +++ b/qwt/tests/test_backingstore.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +SHOW = False # Do not show test in GUI-based test launcher + +from qwt.tests import utils +from qwt.tests.test_simple import SimplePlot + + +class BackingStorePlot(SimplePlot): + TEST_EXPORT = False + + def __init__(self): + SimplePlot.__init__(self) + self.canvas().setPaintAttribute(self.canvas().BackingStore, True) + + +def test_backingstore(): + """Test for backing store""" + utils.test_widget(BackingStorePlot, size=(600, 400)) + + +if __name__ == "__main__": + test_backingstore() diff --git a/qwt/tests/test_bodedemo.py b/qwt/tests/test_bodedemo.py new file mode 100644 index 0000000..2bbff41 --- /dev/null +++ b/qwt/tests/test_bodedemo.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +import os + +import numpy as np +from qtpy.QtCore import Qt +from qtpy.QtGui import QFont, QIcon, QPageLayout, QPen, QPixmap +from qtpy.QtPrintSupport import QPrintDialog, QPrinter +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QToolBar, + QToolButton, + QWidget, +) + +from qwt import ( + QwtLegend, + QwtLogScaleEngine, + QwtPlot, + QwtPlotCurve, + QwtPlotGrid, + QwtPlotMarker, + QwtPlotRenderer, + QwtSymbol, + QwtText, +) +from qwt.tests import utils + +print_xpm = [ + "32 32 12 1", + "a c #ffffff", + "h c #ffff00", + "c c #ffffff", + "f c #dcdcdc", + "b c #c0c0c0", + "j c #a0a0a4", + "e c #808080", + "g c #808000", + "d c #585858", + "i c #00ff00", + "# c #000000", + ". c None", + "................................", + "................................", + "...........###..................", + "..........#abb###...............", + ".........#aabbbbb###............", + ".........#ddaaabbbbb###.........", + "........#ddddddaaabbbbb###......", + ".......#deffddddddaaabbbbb###...", + "......#deaaabbbddddddaaabbbbb###", + ".....#deaaaaaaabbbddddddaaabbbb#", + "....#deaaabbbaaaa#ddedddfggaaad#", + "...#deaaaaaaaaaa#ddeeeeafgggfdd#", + "..#deaaabbbaaaa#ddeeeeabbbbgfdd#", + ".#deeefaaaaaaa#ddeeeeabbhhbbadd#", + "#aabbbeeefaaa#ddeeeeabbbbbbaddd#", + "#bbaaabbbeee#ddeeeeabbiibbadddd#", + "#bbbbbaaabbbeeeeeeabbbbbbaddddd#", + "#bjbbbbbbaaabbbbeabbbbbbadddddd#", + "#bjjjjbbbbbbaaaeabbbbbbaddddddd#", + "#bjaaajjjbbbbbbaaabbbbadddddddd#", + "#bbbbbaaajjjbbbbbbaaaaddddddddd#", + "#bjbbbbbbaaajjjbbbbbbddddddddd#.", + "#bjjjjbbbbbbaaajjjbbbdddddddd#..", + "#bjaaajjjbbbbbbjaajjbddddddd#...", + "#bbbbbaaajjjbbbjbbaabdddddd#....", + "###bbbbbbaaajjjjbbbbbddddd#.....", + "...###bbbbbbaaajbbbbbdddd#......", + "......###bbbbbbjbbbbbddd#.......", + ".........###bbbbbbbbbdd#........", + "............###bbbbbbd#.........", + "...............###bbb#..........", + "..................###...........", +] + + +class BodePlot(QwtPlot): + def __init__(self, *args): + QwtPlot.__init__(self, *args) + + self.setTitle("Frequency Response of a 2nd-order System") + self.setCanvasBackground(Qt.darkBlue) + + # legend + legend = QwtLegend() + legend.setFrameStyle(QFrame.Box | QFrame.Sunken) + self.insertLegend(legend, QwtPlot.BottomLegend) + + # grid + QwtPlotGrid.make(plot=self, enableminor=(True, False), color=Qt.darkGray) + + # axes + self.enableAxis(QwtPlot.yRight) + self.setAxisTitle(QwtPlot.xBottom, "\u03c9/\u03c90") + self.setAxisTitle(QwtPlot.yLeft, "Amplitude [dB]") + self.setAxisTitle(QwtPlot.yRight, "Phase [\u00b0]") + + self.setAxisMaxMajor(QwtPlot.xBottom, 6) + self.setAxisMaxMinor(QwtPlot.xBottom, 10) + self.setAxisScaleEngine(QwtPlot.xBottom, QwtLogScaleEngine()) + + # curves + self.curve1 = QwtPlotCurve.make( + title="Amplitude", linecolor=Qt.yellow, plot=self, antialiased=True + ) + self.curve2 = QwtPlotCurve.make( + title="Phase", linecolor=Qt.cyan, plot=self, antialiased=True + ) + self.dB3Marker = QwtPlotMarker.make( + label=QwtText.make(color=Qt.white, brush=Qt.red, weight=QFont.Light), + linestyle=QwtPlotMarker.VLine, + align=Qt.AlignRight | Qt.AlignBottom, + color=Qt.green, + width=2, + style=Qt.DashDotLine, + plot=self, + ) + self.peakMarker = QwtPlotMarker.make( + label=QwtText.make( + color=Qt.red, brush=self.canvasBackground(), weight=QFont.Bold + ), + symbol=QwtSymbol.make(QwtSymbol.Diamond, Qt.yellow, Qt.green, (7, 7)), + linestyle=QwtPlotMarker.HLine, + align=Qt.AlignRight | Qt.AlignBottom, + color=Qt.red, + width=2, + style=Qt.DashDotLine, + plot=self, + ) + QwtPlotMarker.make( + xvalue=0.1, + yvalue=-20.0, + align=Qt.AlignRight | Qt.AlignBottom, + label=QwtText.make( + "[1-(\u03c9/\u03c90)2+2j\u03c9/Q]-1", + color=Qt.white, + borderradius=2, + borderpen=QPen(Qt.lightGray, 5), + brush=Qt.lightGray, + weight=QFont.Bold, + ), + plot=self, + ) + + self.setDamp(0.01) + + def showData(self, frequency, amplitude, phase): + self.curve1.setData(frequency, amplitude) + self.curve2.setData(frequency, phase) + + def showPeak(self, frequency, amplitude): + self.peakMarker.setValue(frequency, amplitude) + label = self.peakMarker.label() + label.setText("Peak: %4g dB" % amplitude) + self.peakMarker.setLabel(label) + + def show3dB(self, frequency): + self.dB3Marker.setValue(frequency, 0.0) + label = self.dB3Marker.label() + label.setText("-3dB at f = %4g" % frequency) + self.dB3Marker.setLabel(label) + + def setDamp(self, d): + self.damping = d + # Numerical Python: f, g, a and p are NumPy arrays! + f = np.exp(np.log(10.0) * np.arange(-2, 2.02, 0.04)) + g = 1.0 / (1.0 - f * f + 2j * self.damping * f) + a = 20.0 * np.log10(abs(g)) + p = 180 * np.arctan2(g.imag, g.real) / np.pi + # for show3dB + i3 = np.argmax(np.where(np.less(a, -3.0), a, -100.0)) + f3 = f[i3] - (a[i3] + 3.0) * (f[i3] - f[i3 - 1]) / (a[i3] - a[i3 - 1]) + # for showPeak + imax = np.argmax(a) + + self.showPeak(f[imax], a[imax]) + self.show3dB(f3) + self.showData(f, a, p) + + self.replot() + + +FNAME_PDF = "bode.pdf" + + +class BodeDemo(QMainWindow): + def __init__(self, *args): + QMainWindow.__init__(self, *args) + + self.plot = BodePlot(self) + self.plot.setContentsMargins(5, 5, 5, 0) + + self.setContextMenuPolicy(Qt.NoContextMenu) + + self.setCentralWidget(self.plot) + + toolBar = QToolBar(self) + self.addToolBar(toolBar) + + btnPrint = QToolButton(toolBar) + btnPrint.setText("Print") + btnPrint.setIcon(QIcon(QPixmap(print_xpm))) + btnPrint.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + toolBar.addWidget(btnPrint) + btnPrint.clicked.connect(self.print_) + + btnExport = QToolButton(toolBar) + btnExport.setText("Export") + btnExport.setIcon(QIcon(QPixmap(print_xpm))) + btnExport.setToolButtonStyle(Qt.ToolButtonTextUnderIcon) + toolBar.addWidget(btnExport) + btnExport.clicked.connect(self.exportDocument) + + toolBar.addSeparator() + + dampBox = QWidget(toolBar) + dampLayout = QHBoxLayout(dampBox) + dampLayout.setSpacing(0) + dampLayout.addWidget(QWidget(dampBox), 10) # spacer + dampLayout.addWidget(QLabel("Damping Factor", dampBox), 0) + dampLayout.addSpacing(10) + + toolBar.addWidget(dampBox) + + self.statusBar() + + self.showInfo() + + if utils.TestEnvironment().unattended: + self.print_(unattended=True) + + def print_(self, unattended=False): + try: + mode = QPrinter.HighResolution + printer = QPrinter(mode) + except AttributeError: + # Some PySide6 / PyQt6 versions do not have this attribute on Linux + printer = QPrinter() + + printer.setCreator("Bode example") + printer.setPageOrientation(QPageLayout.Landscape) + try: + printer.setColorMode(QPrinter.Color) + except AttributeError: + pass + + docName = str(self.plot.title().text()) + if not docName: + docName.replace("\n", " -- ") + printer.setDocName(docName) + + dialog = QPrintDialog(printer) + if unattended: + # Configure QPrinter object to print to PDF file + printer.setPrinterName("") + printer.setOutputFileName(FNAME_PDF) + dialog.accept() + ok = True + else: + ok = dialog.exec_() + if ok: + renderer = QwtPlotRenderer() + renderer.renderTo(self.plot, printer) + + def exportDocument(self): + renderer = QwtPlotRenderer(self.plot) + renderer.exportTo(self.plot, "bode") + + def showInfo(self, text=""): + self.statusBar().showMessage(text) + + def moved(self, point): + info = "Freq=%g, Ampl=%g, Phase=%g" % ( + self.plot.invTransform(QwtPlot.xBottom, point.x()), + self.plot.invTransform(QwtPlot.yLeft, point.y()), + self.plot.invTransform(QwtPlot.yRight, point.y()), + ) + self.showInfo(info) + + def selected(self, _): + self.showInfo() + + +def test_bodedemo(): + """Bode demo""" + utils.test_widget(BodeDemo, (640, 480)) + if os.path.isfile(FNAME_PDF): + os.remove(FNAME_PDF) + + +if __name__ == "__main__": + test_bodedemo() diff --git a/examples/CartesianDemo.py b/qwt/tests/test_cartesian.py similarity index 59% rename from examples/CartesianDemo.py rename to qwt/tests/test_cartesian.py index f205923..507b551 100644 --- a/examples/CartesianDemo.py +++ b/qwt/tests/test_cartesian.py @@ -2,21 +2,23 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) -import sys +SHOW = True # Show test in GUI-based test launcher + import numpy as np +from qtpy.QtCore import Qt -from qwt.qt.QtGui import QApplication, QPen -from qwt.qt.QtCore import Qt -from qwt import QwtPlot, QwtScaleDraw, QwtPlotGrid, QwtPlotCurve, QwtPlotItem +from qwt import QwtPlot, QwtPlotCurve, QwtPlotGrid, QwtPlotItem, QwtScaleDraw +from qwt.tests import utils class CartesianAxis(QwtPlotItem): - """Supports a coordinate system similar to + """Supports a coordinate system similar to http://en.wikipedia.org/wiki/Image:Cartesian-coordinate-system.svg""" + def __init__(self, masterAxis, slaveAxis): """Valid input values for masterAxis and slaveAxis are QwtPlot.yLeft, QwtPlot.yRight, QwtPlot.xBottom, and QwtPlot.xTop. When masterAxis is @@ -28,10 +30,14 @@ def __init__(self, masterAxis, slaveAxis): else: self.setAxes(masterAxis, slaveAxis) self.scaleDraw = QwtScaleDraw() - self.scaleDraw.setAlignment((QwtScaleDraw.LeftScale, - QwtScaleDraw.RightScale, - QwtScaleDraw.BottomScale, - QwtScaleDraw.TopScale)[masterAxis]) + self.scaleDraw.setAlignment( + ( + QwtScaleDraw.LeftScale, + QwtScaleDraw.RightScale, + QwtScaleDraw.BottomScale, + QwtScaleDraw.TopScale, + )[masterAxis] + ) def draw(self, painter, xMap, yMap, rect): """Draw an axis on the plot canvas""" @@ -39,29 +45,28 @@ def draw(self, painter, xMap, yMap, rect): ytr = yMap.transform if self.__axis in (QwtPlot.yLeft, QwtPlot.yRight): self.scaleDraw.move(round(xtr(0.0)), yMap.p2()) - self.scaleDraw.setLength(yMap.p1()-yMap.p2()) + self.scaleDraw.setLength(yMap.p1() - yMap.p2()) elif self.__axis in (QwtPlot.xBottom, QwtPlot.xTop): self.scaleDraw.move(xMap.p1(), round(ytr(0.0))) - self.scaleDraw.setLength(xMap.p2()-xMap.p1()) + self.scaleDraw.setLength(xMap.p2() - xMap.p1()) self.scaleDraw.setScaleDiv(self.plot().axisScaleDiv(self.__axis)) self.scaleDraw.draw(painter, self.plot().palette()) class CartesianPlot(QwtPlot): - """Creates a coordinate system similar system + """Creates a coordinate system similar system http://en.wikipedia.org/wiki/Image:Cartesian-coordinate-system.svg""" + def __init__(self, *args): QwtPlot.__init__(self, *args) - self.setTitle('Cartesian Coordinate System Demo') + self.setTitle("Cartesian Coordinate System Demo") # create a plot with a white canvas self.setCanvasBackground(Qt.white) # set plot layout self.plotLayout().setCanvasMargin(0) self.plotLayout().setAlignCanvasToScales(True) # attach a grid - grid = QwtPlotGrid() - grid.attach(self) - grid.setPen(QPen(Qt.black, 0, Qt.DotLine)) + QwtPlotGrid.make(self, color=Qt.lightGray, width=0, style=Qt.DotLine, z=-1) # attach a x-axis xaxis = CartesianAxis(QwtPlot.xBottom, QwtPlot.yLeft) xaxis.attach(self) @@ -71,30 +76,34 @@ def __init__(self, *args): yaxis.attach(self) self.enableAxis(QwtPlot.yLeft, False) # calculate 3 NumPy arrays - x = np.arange(-2*np.pi, 2*np.pi, 0.01) - y = np.pi*np.sin(x) - z = 4*np.pi*np.cos(x)*np.cos(x)*np.sin(x) + x = np.arange(-2 * np.pi, 2 * np.pi, 0.01) # attach a curve - curve = QwtPlotCurve('y = pi*sin(x)') - curve.attach(self) - curve.setPen(QPen(Qt.green, 2)) - curve.setData(x, y) + QwtPlotCurve.make( + x, + np.pi * np.sin(x), + title="y = pi*sin(x)", + linecolor=Qt.green, + linewidth=2, + plot=self, + antialiased=True, + ) # attach another curve - curve = QwtPlotCurve('y = 4*pi*sin(x)*cos(x)**2') - curve.attach(self) - curve.setPen(QPen(Qt.black, 2)) - curve.setData(x, z) + QwtPlotCurve.make( + x, + 4 * np.pi * np.cos(x) * np.cos(x) * np.sin(x), + title="y = 4*pi*sin(x)*cos(x)**2", + linecolor=Qt.blue, + linewidth=2, + plot=self, + antialiased=True, + ) self.replot() -def make(): - demo = CartesianPlot() - demo.resize(400, 300) - demo.show() - return demo +def test_cartesian(): + """Cartesian plot test""" + utils.test_widget(CartesianPlot, (800, 480)) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) +if __name__ == "__main__": + test_cartesian() diff --git a/qwt/tests/test_cpudemo.py b/qwt/tests/test_cpudemo.py new file mode 100644 index 0000000..18ccdf7 --- /dev/null +++ b/qwt/tests/test_cpudemo.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +import os + +import numpy as np +from qtpy.QtCore import QRectF, Qt, QTime +from qtpy.QtGui import QBrush, QColor +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from qwt import ( + QwtLegend, + QwtLegendData, + QwtPlot, + QwtPlotCurve, + QwtPlotItem, + QwtPlotMarker, + QwtScaleDraw, + QwtText, +) +from qwt.tests import utils + + +class CpuStat: + User = 0 + Nice = 1 + System = 2 + Idle = 3 + counter = 0 + dummyValues = ( + (103726, 0, 23484, 819556), + (103783, 0, 23489, 819604), + (103798, 0, 23490, 819688), + (103820, 0, 23490, 819766), + (103840, 0, 23493, 819843), + (103875, 0, 23499, 819902), + (103917, 0, 23504, 819955), + (103950, 0, 23508, 820018), + (103987, 0, 23510, 820079), + (104020, 0, 23513, 820143), + (104058, 0, 23514, 820204), + (104099, 0, 23520, 820257), + (104121, 0, 23525, 820330), + (104159, 0, 23530, 820387), + (104176, 0, 23534, 820466), + (104215, 0, 23538, 820523), + (104245, 0, 23541, 820590), + (104267, 0, 23545, 820664), + (104311, 0, 23555, 820710), + (104355, 0, 23565, 820756), + (104367, 0, 23567, 820842), + (104383, 0, 23572, 820921), + (104396, 0, 23577, 821003), + (104413, 0, 23579, 821084), + (104446, 0, 23588, 821142), + (104521, 0, 23594, 821161), + (104611, 0, 23604, 821161), + (104708, 0, 23607, 821161), + (104804, 0, 23611, 821161), + (104895, 0, 23620, 821161), + (104993, 0, 23622, 821161), + (105089, 0, 23626, 821161), + (105185, 0, 23630, 821161), + (105281, 0, 23634, 821161), + (105379, 0, 23636, 821161), + (105472, 0, 23643, 821161), + (105569, 0, 23646, 821161), + (105666, 0, 23649, 821161), + (105763, 0, 23652, 821161), + (105828, 0, 23661, 821187), + (105904, 0, 23666, 821206), + (105999, 0, 23671, 821206), + (106094, 0, 23676, 821206), + (106184, 0, 23686, 821206), + (106273, 0, 23692, 821211), + (106306, 0, 23700, 821270), + (106341, 0, 23703, 821332), + (106392, 0, 23709, 821375), + (106423, 0, 23715, 821438), + (106472, 0, 23721, 821483), + (106531, 0, 23727, 821517), + (106562, 0, 23732, 821582), + (106597, 0, 23736, 821643), + (106633, 0, 23737, 821706), + (106666, 0, 23742, 821768), + (106697, 0, 23744, 821835), + (106730, 0, 23748, 821898), + (106765, 0, 23751, 821960), + (106799, 0, 23754, 822023), + (106831, 0, 23758, 822087), + (106862, 0, 23761, 822153), + (106899, 0, 23763, 822214), + (106932, 0, 23766, 822278), + (106965, 0, 23768, 822343), + (107009, 0, 23771, 822396), + (107040, 0, 23775, 822461), + (107092, 0, 23780, 822504), + (107143, 0, 23787, 822546), + (107200, 0, 23795, 822581), + (107250, 0, 23803, 822623), + (107277, 0, 23810, 822689), + (107286, 0, 23810, 822780), + (107313, 0, 23817, 822846), + (107325, 0, 23818, 822933), + (107332, 0, 23818, 823026), + (107344, 0, 23821, 823111), + (107357, 0, 23821, 823198), + (107368, 0, 23823, 823284), + (107375, 0, 23824, 823377), + (107386, 0, 23825, 823465), + (107396, 0, 23826, 823554), + (107422, 0, 23830, 823624), + (107434, 0, 23831, 823711), + (107456, 0, 23835, 823785), + (107468, 0, 23838, 823870), + (107487, 0, 23840, 823949), + (107515, 0, 23843, 824018), + (107528, 0, 23846, 824102), + (107535, 0, 23851, 824190), + (107548, 0, 23853, 824275), + (107562, 0, 23857, 824357), + (107656, 0, 23863, 824357), + (107751, 0, 23868, 824357), + (107849, 0, 23870, 824357), + (107944, 0, 23875, 824357), + (108043, 0, 23876, 824357), + (108137, 0, 23882, 824357), + (108230, 0, 23889, 824357), + (108317, 0, 23902, 824357), + (108412, 0, 23907, 824357), + (108511, 0, 23908, 824357), + (108608, 0, 23911, 824357), + (108704, 0, 23915, 824357), + (108801, 0, 23918, 824357), + (108891, 0, 23928, 824357), + (108987, 0, 23932, 824357), + (109072, 0, 23943, 824361), + (109079, 0, 23943, 824454), + (109086, 0, 23944, 824546), + (109098, 0, 23950, 824628), + (109108, 0, 23955, 824713), + (109115, 0, 23957, 824804), + (109122, 0, 23958, 824896), + (109132, 0, 23959, 824985), + (109142, 0, 23961, 825073), + (109146, 0, 23962, 825168), + (109153, 0, 23964, 825259), + (109162, 0, 23966, 825348), + (109168, 0, 23969, 825439), + (109176, 0, 23971, 825529), + (109185, 0, 23974, 825617), + (109193, 0, 23977, 825706), + (109198, 0, 23978, 825800), + (109206, 0, 23978, 825892), + (109212, 0, 23981, 825983), + (109219, 0, 23981, 826076), + (109225, 0, 23981, 826170), + (109232, 0, 23984, 826260), + (109242, 0, 23984, 826350), + (109255, 0, 23986, 826435), + (109268, 0, 23987, 826521), + (109283, 0, 23990, 826603), + (109288, 0, 23991, 826697), + (109295, 0, 23993, 826788), + (109308, 0, 23994, 826874), + (109322, 0, 24009, 826945), + (109328, 0, 24011, 827037), + (109338, 0, 24012, 827126), + (109347, 0, 24012, 827217), + (109354, 0, 24017, 827305), + (109367, 0, 24017, 827392), + (109371, 0, 24019, 827486), + ) + + def __init__(self): + self.procValues = self.__lookup() + + def statistic(self): + values = self.__lookup() + userDelta = 0.0 + for i in [CpuStat.User, CpuStat.Nice]: + userDelta += values[i] - self.procValues[i] + systemDelta = values[CpuStat.System] - self.procValues[CpuStat.System] + totalDelta = 0.0 + for i in range(len(self.procValues)): + totalDelta += values[i] - self.procValues[i] + self.procValues = values + return 100.0 * userDelta / totalDelta, 100.0 * systemDelta / totalDelta + + def upTime(self): + result = QTime(0, 0, 0) + for item in self.procValues: + result = result.addSecs(int(0.01 * item)) + return result + + def __lookup(self): + if os.path.exists("/proc/stat"): + with open("/proc/stat") as file: + for line in file: + words = line.split() + if words[0] == "cpu" and len(words) >= 5: + return [float(w) for w in words[1:]] + else: + result = CpuStat.dummyValues[CpuStat.counter] + CpuStat.counter += 1 + CpuStat.counter %= len(CpuStat.dummyValues) + return result + + +class CpuPieMarker(QwtPlotMarker): + def __init__(self, *args): + QwtPlotMarker.__init__(self, *args) + self.setZ(1000.0) + self.setRenderHint(QwtPlotItem.RenderAntialiased, True) + + def rtti(self): + return QwtPlotItem.Rtti_PlotUserItem + + def draw(self, painter, xMap, yMap, rect): + margin = 5 + pieRect = QRectF() + pieRect.setX(rect.x() + margin) + pieRect.setY(rect.y() + margin) + pieRect.setHeight(int(yMap.transform(80.0))) + pieRect.setWidth(pieRect.height()) + + angle = 3 * 5760 / 4 + for key in ["User", "System", "Idle"]: + curve = self.plot().cpuPlotCurve(key) + if curve.dataSize(): + value = int(5760 * curve.sample(0).y() / 100.0) + painter.save() + painter.setBrush(QBrush(curve.pen().color(), Qt.SolidPattern)) + painter.drawPie(pieRect, int(-angle), int(-value)) + painter.restore() + angle += value + + +class TimeScaleDraw(QwtScaleDraw): + def __init__(self, baseTime, *args): + QwtScaleDraw.__init__(self, *args) + self.baseTime = baseTime + + def label(self, value): + upTime = self.baseTime.addSecs(int(value)) + return QwtText(upTime.toString()) + + +class Background(QwtPlotItem): + def __init__(self): + QwtPlotItem.__init__(self) + self.setZ(0.0) + + def rtti(self): + return QwtPlotItem.Rtti_PlotUserItem + + def draw(self, painter, xMap, yMap, rect): + c = QColor(Qt.white) + r = QRectF(rect) + + for i in range(100, 0, -10): + r.setBottom(int(yMap.transform(i - 10))) + r.setTop(int(yMap.transform(i))) + painter.fillRect(r, c) + c = c.darker(110) + + +class CpuCurve(QwtPlotCurve): + def __init__(self, *args): + QwtPlotCurve.__init__(self, *args) + self.setRenderHint(QwtPlotItem.RenderAntialiased) + + def setColor(self, color): + c = QColor(color) + c.setAlpha(150) + + self.setPen(c) + self.setBrush(c) + + +class CpuPlot(QwtPlot): + HISTORY = 60 + + def __init__(self, unattended=False): + QwtPlot.__init__(self) + + self.curves = {} + self.data = {} + self.timeData = 1.0 * np.arange(self.HISTORY - 1, -1, -1) + self.cpuStat = CpuStat() + + self.setAutoReplot(False) + + self.plotLayout().setAlignCanvasToScales(True) + + legend = QwtLegend() + legend.setDefaultItemMode(QwtLegendData.Checkable) + self.insertLegend(legend, QwtPlot.RightLegend) + + self.setAxisTitle(QwtPlot.xBottom, "System Uptime [h:m:s]") + self.setAxisScaleDraw(QwtPlot.xBottom, TimeScaleDraw(self.cpuStat.upTime())) + self.setAxisScale(QwtPlot.xBottom, 0, self.HISTORY) + self.setAxisLabelRotation(QwtPlot.xBottom, -50.0) + self.setAxisLabelAlignment(QwtPlot.xBottom, Qt.AlignLeft | Qt.AlignBottom) + + self.setAxisTitle(QwtPlot.yLeft, "Cpu Usage [%]") + self.setAxisScale(QwtPlot.yLeft, 0, 100) + + background = Background() + background.attach(self) + + pie = CpuPieMarker() + pie.attach(self) + + curve = CpuCurve("System") + curve.setColor(Qt.red) + curve.attach(self) + self.curves["System"] = curve + self.data["System"] = np.zeros(self.HISTORY, float) + + curve = CpuCurve("User") + curve.setColor(Qt.blue) + curve.setZ(curve.z() - 1.0) + curve.attach(self) + self.curves["User"] = curve + self.data["User"] = np.zeros(self.HISTORY, float) + + curve = CpuCurve("Total") + curve.setColor(Qt.black) + curve.setZ(curve.z() - 2.0) + curve.attach(self) + self.curves["Total"] = curve + self.data["Total"] = np.zeros(self.HISTORY, float) + + curve = CpuCurve("Idle") + curve.setColor(Qt.darkCyan) + curve.setZ(curve.z() - 3.0) + curve.attach(self) + self.curves["Idle"] = curve + self.data["Idle"] = np.zeros(self.HISTORY, float) + + self.showCurve(self.curves["System"], True) + self.showCurve(self.curves["User"], True) + self.showCurve(self.curves["Total"], False or unattended) + self.showCurve(self.curves["Idle"], False or unattended) + + self.startTimer(20 if unattended else 1000) + + legend.checked.connect(self.showCurve) + self.replot() + + def timerEvent(self, e): + for data in self.data.values(): + data[1:] = data[0:-1] + self.data["User"][0], self.data["System"][0] = self.cpuStat.statistic() + self.data["Total"][0] = self.data["User"][0] + self.data["System"][0] + self.data["Idle"][0] = 100.0 - self.data["Total"][0] + + self.timeData += 1.0 + + self.setAxisScale(QwtPlot.xBottom, self.timeData[-1], self.timeData[0]) + for key in self.curves.keys(): + self.curves[key].setData(self.timeData, self.data[key]) + + self.replot() + + def showCurve(self, item, on, index=None): + item.setVisible(on) + self.legend().legendWidget(item).setChecked(on) + self.replot() + + def cpuPlotCurve(self, key): + return self.curves[key] + + +class CpuDemo(QWidget): + def __init__(self, parent=None, unattended=False): + super(CpuDemo, self).__init__(parent) + layout = QVBoxLayout() + self.setLayout(layout) + plot = CpuPlot(unattended=unattended) + plot.setTitle("History") + layout.addWidget(plot) + label = QLabel("Press the legend to en/disable a curve") + layout.addWidget(label) + + +def test_cpudemo(): + """CPU demo""" + utils.test_widget(CpuDemo, (600, 400)) + + +if __name__ == "__main__": + test_cpudemo() diff --git a/qwt/tests/test_curvebenchmark1.py b/qwt/tests/test_curvebenchmark1.py new file mode 100644 index 0000000..8032fa5 --- /dev/null +++ b/qwt/tests/test_curvebenchmark1.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +"""Curve benchmark example""" + +SHOW = True # Show test in GUI-based test launcher + +import time + +import numpy as np +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QApplication, + QGridLayout, + QLineEdit, + QMainWindow, + QTabWidget, + QTextEdit, + QWidget, +) + +from qwt import QwtPlot, QwtPlotCurve +from qwt.tests import utils + +COLOR_INDEX = None + + +def get_curve_color(): + global COLOR_INDEX + colors = (Qt.blue, Qt.red, Qt.green, Qt.yellow, Qt.magenta, Qt.cyan) + if COLOR_INDEX is None: + COLOR_INDEX = 0 + else: + COLOR_INDEX = (COLOR_INDEX + 1) % len(colors) + return colors[COLOR_INDEX] + + +PLOT_ID = 0 + + +class BMPlot(QwtPlot): + def __init__(self, title, xdata, ydata, style, symbol=None, *args): + super(BMPlot, self).__init__(*args) + global PLOT_ID + self.setMinimumSize(200, 150) + PLOT_ID += 1 + self.setTitle("%s (#%d)" % (title, PLOT_ID)) + self.setAxisTitle(QwtPlot.xBottom, "x") + self.setAxisTitle(QwtPlot.yLeft, "y") + self.curve_nb = 0 + for idx in range(1, 11): + self.curve_nb += 1 + QwtPlotCurve.make( + xdata, + ydata * idx, + style=style, + symbol=symbol, + linecolor=get_curve_color(), + antialiased=True, + plot=self, + ) + self.replot() + + +class BMWidget(QWidget): + def __init__(self, nbcol, points, *args, **kwargs): + super(BMWidget, self).__init__() + self.plot_nb = 0 + self.curve_nb = 0 + self.setup(nbcol, points, *args, **kwargs) + + def params(self, *args, **kwargs): + if kwargs.get("only_lines", False): + return (("Lines", None),) + else: + return ( + ("Lines", None), + ("Dots", None), + ) + + def setup(self, nbcol, points, *args, **kwargs): + x = np.linspace(0.001, 20.0, int(points)) + y = (np.sin(x) / x) * np.cos(20 * x) + layout = QGridLayout() + col, row = 0, 0 + for style, symbol in self.params(*args, **kwargs): + plot = BMPlot(style, x, y, getattr(QwtPlotCurve, style), symbol=symbol) + layout.addWidget(plot, row, col) + self.plot_nb += 1 + self.curve_nb += plot.curve_nb + col += 1 + if col >= nbcol: + row += 1 + col = 0 + self.text = QLineEdit() + self.text.setReadOnly(True) + self.text.setAlignment(Qt.AlignCenter) + self.text.setText("Rendering plot...") + layout.addWidget(self.text, row + 1, 0, 1, nbcol) + self.setLayout(layout) + + +class BMText(QTextEdit): + def __init__(self, parent=None, title=None): + super(BMText, self).__init__(parent) + self.setReadOnly(True) + library = "PythonQwt" + wintitle = self.parent().windowTitle() + if not wintitle: + wintitle = "Benchmark" + if title is None: + title = "%s example" % wintitle + self.parent().setWindowTitle("%s [%s]" % (wintitle, library)) + self.setText( + """\ +%s:
+(base plotting library: %s)

+Click on each tab to test if plotting performance is acceptable in terms of +GUI response time (switch between tabs, resize main windows, ...).
+

+Benchmarks results: +""" + % (title, library) + ) + + +class CurveBenchmark1(QMainWindow): + TITLE = "Curve benchmark" + SIZE = (1000, 500) + + def __init__(self, max_n=1000000, parent=None, unattended=False, **kwargs): + super(CurveBenchmark1, self).__init__(parent=parent) + title = self.TITLE + if kwargs.get("only_lines", False): + title = "%s (%s)" % (title, "only lines") + self.setWindowTitle(title) + self.tabs = QTabWidget() + self.setCentralWidget(self.tabs) + self.text = BMText(self) + self.tabs.addTab(self.text, "Contents") + self.resize(*self.SIZE) + self.durations = [] + + # Force window to show up and refresh (for test purpose only) + self.show() + QApplication.processEvents() + + t0g = time.time() + self.run_benchmark(max_n, unattended, **kwargs) + dt = time.time() - t0g + self.text.append("

Total elapsed time: %d ms" % (dt * 1e3)) + self.tabs.setCurrentIndex(1 if unattended else 0) + + def process_iteration(self, title, description, widget, t0): + self.tabs.addTab(widget, title) + self.tabs.setCurrentWidget(widget) + + # Force widget to refresh (for test purpose only) + QApplication.processEvents() + + duration = (time.time() - t0) * 1000 + self.durations.append(duration) + time_str = "Elapsed time: %d ms" % duration + widget.text.setText(time_str) + self.text.append("
%s:
%s" % (description, time_str)) + print("[%s] %s" % (utils.get_lib_versions(), time_str)) + + def run_benchmark(self, max_n, unattended, **kwargs): + max_n = 1000 if unattended else max_n + iterations = 0 if unattended else 4 + for idx in range(iterations, -1, -1): + points = int(max_n / 10**idx) + t0 = time.time() + widget = BMWidget(2, points, **kwargs) + title = "%d points" % points + description = "%d plots with %d curves of %d points" % ( + widget.plot_nb, + widget.curve_nb, + points, + ) + self.process_iteration(title, description, widget, t0) + + +def test_curvebenchmark1(): + """Curve benchmark example""" + utils.test_widget(CurveBenchmark1, options=False) + + +if __name__ == "__main__": + test_curvebenchmark1() diff --git a/qwt/tests/test_curvebenchmark2.py b/qwt/tests/test_curvebenchmark2.py new file mode 100644 index 0000000..c8ded00 --- /dev/null +++ b/qwt/tests/test_curvebenchmark2.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +"""Curve styles""" + +SHOW = True # Show test in GUI-based test launcher + +import time + +from qtpy.QtCore import Qt + +from qwt import QwtSymbol +from qwt.tests import test_curvebenchmark1 as cb +from qwt.tests import utils + + +class CSWidget(cb.BMWidget): + def params(self, *args, **kwargs): + (symbols,) = args + symb1 = QwtSymbol.make( + QwtSymbol.Ellipse, brush=Qt.yellow, pen=Qt.blue, size=(5, 5) + ) + symb2 = QwtSymbol.make(QwtSymbol.XCross, pen=Qt.darkMagenta, size=(5, 5)) + if symbols: + if kwargs.get("only_lines", False): + return ( + ("Lines", symb1), + ("Lines", symb1), + ("Lines", symb2), + ("Lines", symb2), + ) + else: + return ( + ("Sticks", symb1), + ("Lines", symb1), + ("Steps", symb2), + ("Dots", symb2), + ) + else: + if kwargs.get("only_lines", False): + return ( + ("Lines", None), + ("Lines", None), + ("Lines", None), + ("Lines", None), + ) + else: + return ( + ("Sticks", None), + ("Lines", None), + ("Steps", None), + ("Dots", None), + ) + + +class CurveBenchmark2(cb.CurveBenchmark1): + TITLE = "Curve styles" + SIZE = (1000, 800) + + def __init__(self, max_n=1000, parent=None, unattended=False, **kwargs): + super(CurveBenchmark2, self).__init__( + max_n=max_n, parent=parent, unattended=unattended, **kwargs + ) + + def run_benchmark(self, max_n, unattended, **kwargs): + for points, symbols in zip( + (max_n / 10, max_n / 10, max_n, max_n), (True, False) * 2 + ): + t0 = time.time() + symtext = "with%s symbols" % ("" if symbols else "out") + widget = CSWidget(2, points, symbols, **kwargs) + title = "%d points" % points + description = "%d plots with %d curves of %d points, %s" % ( + widget.plot_nb, + widget.curve_nb, + points, + symtext, + ) + self.process_iteration(title, description, widget, t0) + + +def test_curvebenchmark2(): + """Curve styles benchmark example""" + utils.test_widget(CurveBenchmark2, options=False) + + +if __name__ == "__main__": + test_curvebenchmark2() diff --git a/examples/CurveDemo1.py b/qwt/tests/test_curvedemo1.py similarity index 57% rename from examples/CurveDemo1.py rename to qwt/tests/test_curvedemo1.py index 545c056..0f37949 100644 --- a/examples/CurveDemo1.py +++ b/qwt/tests/test_curvedemo1.py @@ -2,21 +2,22 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) -import sys +SHOW = True # Show test in GUI-based test launcher + import numpy as np +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QBrush, QFont, QPainter, QPen +from qtpy.QtWidgets import QFrame -from qwt.qt.QtGui import (QApplication, QPen, QBrush, QFrame, QFont, QPainter, - QPaintEngine) -from qwt.qt.QtCore import QSize -from qwt.qt.QtCore import Qt -from qwt import QwtSymbol, QwtPlotCurve, QwtPlotItem, QwtScaleMap +from qwt import QwtPlotCurve, QwtPlotItem, QwtScaleMap, QwtSymbol +from qwt.tests import utils -class CurveDemo(QFrame): +class CurveDemo1(QFrame): def __init__(self, *args): QFrame.__init__(self, *args) @@ -31,49 +32,47 @@ def __init__(self, *args): self.setMidLineWidth(3) # calculate values - self.x = np.arange(0, 10.0, 10.0/27) - self.y = np.sin(self.x)*np.cos(2*self.x) - + self.x = np.arange(0, 10.0, 10.0 / 27) + self.y = np.sin(self.x) * np.cos(2 * self.x) + # make curves with different styles self.curves = [] self.titles = [] # curve 1 - self.titles.append('Style: Sticks, Symbol: Ellipse') + self.titles.append("Style: Sticks, Symbol: Ellipse") curve = QwtPlotCurve() curve.setPen(QPen(Qt.red)) curve.setStyle(QwtPlotCurve.Sticks) - curve.setSymbol(QwtSymbol(QwtSymbol.Ellipse, - QBrush(Qt.yellow), - QPen(Qt.blue), - QSize(5, 5))) + curve.setSymbol( + QwtSymbol(QwtSymbol.Ellipse, QBrush(Qt.yellow), QPen(Qt.blue), QSize(5, 5)) + ) self.curves.append(curve) # curve 2 - self.titles.append('Style: Lines, Symbol: None') + self.titles.append("Style: Lines, Symbol: None") curve = QwtPlotCurve() curve.setPen(QPen(Qt.darkBlue)) curve.setStyle(QwtPlotCurve.Lines) self.curves.append(curve) # curve 3 - self.titles.append('Style: Lines, Symbol: None, Antialiased') + self.titles.append("Style: Lines, Symbol: None, Antialiased") curve = QwtPlotCurve() curve.setPen(QPen(Qt.darkBlue)) curve.setStyle(QwtPlotCurve.Lines) curve.setRenderHint(QwtPlotItem.RenderAntialiased) self.curves.append(curve) # curve 4 - self.titles.append('Style: Steps, Symbol: None') + self.titles.append("Style: Steps, Symbol: None") curve = QwtPlotCurve() curve.setPen(QPen(Qt.darkCyan)) curve.setStyle(QwtPlotCurve.Steps) - self.curves.append(curve) + self.curves.append(curve) # curve 5 - self.titles.append('Style: NoCurve, Symbol: XCross') + self.titles.append("Style: NoCurve, Symbol: XCross") curve = QwtPlotCurve() curve.setStyle(QwtPlotCurve.NoCurve) - curve.setSymbol(QwtSymbol(QwtSymbol.XCross, - QBrush(), - QPen(Qt.darkMagenta), - QSize(5, 5))) + curve.setSymbol( + QwtSymbol(QwtSymbol.XCross, QBrush(), QPen(Qt.darkMagenta), QSize(5, 5)) + ) self.curves.append(curve) # attach data, using Numeric @@ -92,38 +91,38 @@ def paintEvent(self, event): def drawContents(self, painter): # draw curves r = self.contentsRect() - dy = r.height()/len(self.curves) + dy = int(r.height() / len(self.curves)) r.setHeight(dy) for curve in self.curves: self.xMap.setPaintInterval(r.left(), r.right()) self.yMap.setPaintInterval(r.top(), r.bottom()) - engine = painter.device().paintEngine() - if engine is not None and engine.hasFeature(QPaintEngine.Antialiasing): - painter.setRenderHint( - QPainter.Antialiasing, - curve.testRenderHint(QwtPlotItem.RenderAntialiased)) + painter.setRenderHint( + QPainter.Antialiasing, + curve.testRenderHint(QwtPlotItem.RenderAntialiased), + ) curve.draw(painter, self.xMap, self.yMap, r) self.shiftDown(r, dy) # draw titles r = self.contentsRect() r.setHeight(dy) - painter.setFont(QFont('Helvetica', 8)) + painter.setFont(QFont("Helvetica", 8)) painter.setPen(Qt.black) for title in self.titles: painter.drawText( - 0, r.top(), r.width(), painter.fontMetrics().height(), - Qt.AlignTop | Qt.AlignHCenter, title) + 0, + r.top(), + r.width(), + painter.fontMetrics().height(), + Qt.AlignTop | Qt.AlignHCenter, + title, + ) self.shiftDown(r, dy) -def make(): - demo = CurveDemo() - demo.resize(300, 600) - demo.show() - return demo +def test_curvedemo1(): + """Curve demo 1""" + utils.test_widget(CurveDemo1, size=(300, 600), options=False) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) +if __name__ == "__main__": + test_curvedemo1() diff --git a/examples/CurveDemo2.py b/qwt/tests/test_curvedemo2.py similarity index 52% rename from examples/CurveDemo2.py rename to qwt/tests/test_curvedemo2.py index 8db5222..ee2ec01 100644 --- a/examples/CurveDemo2.py +++ b/qwt/tests/test_curvedemo2.py @@ -2,26 +2,25 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) -#FIXME: scale issue! +SHOW = True # Show test in GUI-based test launcher -import sys import numpy as np +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QBrush, QColor, QPainter, QPalette, QPen +from qtpy.QtWidgets import QFrame -from qwt.qt.QtGui import (QApplication, QPen, QBrush, QFrame, QColor, QPainter, - QPalette) -from qwt.qt.QtCore import QSize -from qwt.qt.QtCore import Qt -from qwt import QwtScaleMap, QwtSymbol, QwtPlotCurve +from qwt import QwtPlotCurve, QwtScaleMap, QwtSymbol +from qwt.tests import utils Size = 15 USize = 13 -class CurveDemo(QFrame): +class CurveDemo2(QFrame): def __init__(self, *args): QFrame.__init__(self, *args) @@ -38,43 +37,39 @@ def __init__(self, *args): curve = QwtPlotCurve() curve.setPen(QPen(QColor(150, 150, 200), 2)) curve.setStyle(QwtPlotCurve.Lines) - curve.setSymbol(QwtSymbol(QwtSymbol.XCross, - QBrush(), - QPen(Qt.yellow, 2), - QSize(7, 7))) - self.tuples.append((curve, - QwtScaleMap(0, 100, -1.5, 1.5), - QwtScaleMap(0, 100, 0.0, 2*np.pi))) + curve.setSymbol( + QwtSymbol(QwtSymbol.XCross, QBrush(), QPen(Qt.yellow, 2), QSize(7, 7)) + ) + self.tuples.append( + (curve, QwtScaleMap(0, 100, -1.5, 1.5), QwtScaleMap(0, 100, 0.0, 2 * np.pi)) + ) # curve 2 curve = QwtPlotCurve() - curve.setPen(QPen(QColor(200, 150, 50), - 1, - Qt.DashDotDotLine)) + curve.setPen(QPen(QColor(200, 150, 50), 1, Qt.DashDotDotLine)) curve.setStyle(QwtPlotCurve.Sticks) - curve.setSymbol(QwtSymbol(QwtSymbol.Ellipse, - QBrush(Qt.blue), - QPen(Qt.yellow), - QSize(5, 5))) - self.tuples.append((curve, - QwtScaleMap(0, 100, 0.0, 2*np.pi), - QwtScaleMap(0, 100, -3.0, 1.1))) + curve.setSymbol( + QwtSymbol(QwtSymbol.Ellipse, QBrush(Qt.blue), QPen(Qt.yellow), QSize(5, 5)) + ) + self.tuples.append( + (curve, QwtScaleMap(0, 100, 0.0, 2 * np.pi), QwtScaleMap(0, 100, -3.0, 1.1)) + ) # curve 3 curve = QwtPlotCurve() curve.setPen(QPen(QColor(100, 200, 150))) curve.setStyle(QwtPlotCurve.Lines) - self.tuples.append((curve, - QwtScaleMap(0, 100, -1.1, 3.0), - QwtScaleMap(0, 100, -1.1, 3.0))) + self.tuples.append( + (curve, QwtScaleMap(0, 100, -1.1, 3.0), QwtScaleMap(0, 100, -1.1, 3.0)) + ) # curve 4 curve = QwtPlotCurve() curve.setPen(QPen(Qt.red)) curve.setStyle(QwtPlotCurve.Lines) - self.tuples.append((curve, - QwtScaleMap(0, 100, -5.0, 1.1), - QwtScaleMap(0, 100, -1.1, 5.0))) + self.tuples.append( + (curve, QwtScaleMap(0, 100, -5.0, 1.1), QwtScaleMap(0, 100, -1.1, 5.0)) + ) # data self.phase = 0.0 - self.base = np.arange(0.0, 2.01*np.pi, 2*np.pi/(USize-1)) + self.base = np.arange(0.0, 2.01 * np.pi, 2 * np.pi / (USize - 1)) self.uval = np.cos(self.base) self.vval = np.sin(self.base) self.uval[1::2] *= 0.5 @@ -84,7 +79,7 @@ def __init__(self, *args): self.tid = self.startTimer(250) def paintEvent(self, event): - QFrame.paintEvent(self,event) + QFrame.paintEvent(self, event) painter = QPainter(self) painter.setClipRect(self.contentsRect()) self.drawContents(painter) @@ -99,38 +94,34 @@ def drawContents(self, painter): def timerEvent(self, event): self.newValues() self.repaint() - + def newValues(self): phase = self.phase - - self.xval = np.arange(0, 2.01*np.pi, 2*np.pi/(Size-1)) + + self.xval = np.arange(0, 2.01 * np.pi, 2 * np.pi / (Size - 1)) self.yval = np.sin(self.xval - phase) - self.zval = np.cos(3*(self.xval + phase)) - + self.zval = np.cos(3 * (self.xval + phase)) + s = 0.25 * np.sin(phase) - c = np.sqrt(1.0 - s*s) + c = np.sqrt(1.0 - s * s) u = self.uval - self.uval = c*self.uval-s*self.vval - self.vval = c*self.vval+s*u + self.uval = c * self.uval - s * self.vval + self.vval = c * self.vval + s * u self.tuples[0][0].setData(self.yval, self.xval) self.tuples[1][0].setData(self.xval, self.zval) self.tuples[2][0].setData(self.yval, self.zval) self.tuples[3][0].setData(self.uval, self.vval) - - self.phase += 2*np.pi/100 - if self.phase>2*np.pi: + + self.phase += 2 * np.pi / 100 + if self.phase > 2 * np.pi: self.phase = 0.0 -def make(): - demo = CurveDemo() - demo.resize(600, 600) - demo.show() - return demo +def test_curvedemo2(): + """Curve demo 2""" + utils.test_widget(CurveDemo2, options=False) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) \ No newline at end of file +if __name__ == "__main__": + test_curvedemo2() diff --git a/examples/DataDemo.py b/qwt/tests/test_data.py similarity index 55% rename from examples/DataDemo.py rename to qwt/tests/test_data.py index 5eba2b9..99ccd46 100644 --- a/examples/DataDemo.py +++ b/qwt/tests/test_data.py @@ -2,46 +2,53 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) +SHOW = True # Show test in GUI-based test launcher + import random -import sys + import numpy as np +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QBrush, QPen +from qtpy.QtWidgets import QFrame -from qwt.qt.QtGui import QApplication, QPen, QBrush, QFrame -from qwt.qt.QtCore import QSize -from qwt.qt.QtCore import Qt -from qwt import (QwtPlot, QwtPlotMarker, QwtSymbol, QwtLegend, QwtPlotCurve, - QwtAbstractScaleDraw) +from qwt import ( + QwtAbstractScaleDraw, + QwtLegend, + QwtPlot, + QwtPlotCurve, + QwtPlotMarker, + QwtSymbol, +) +from qwt.tests import utils class DataPlot(QwtPlot): - - def __init__(self, *args): - QwtPlot.__init__(self, *args) + def __init__(self, unattended=False): + QwtPlot.__init__(self) self.setCanvasBackground(Qt.white) self.alignScales() # Initialize data self.x = np.arange(0.0, 100.1, 0.5) - self.y = np.zeros(len(self.x), np.float) - self.z = np.zeros(len(self.x), np.float) + self.y = np.zeros(len(self.x), float) + self.z = np.zeros(len(self.x), float) self.setTitle("A Moving QwtPlot Demonstration") - self.insertLegend(QwtLegend(), QwtPlot.BottomLegend); + self.insertLegend(QwtLegend(), QwtPlot.BottomLegend) self.curveR = QwtPlotCurve("Data Moving Right") self.curveR.attach(self) self.curveL = QwtPlotCurve("Data Moving Left") self.curveL.attach(self) - self.curveL.setSymbol(QwtSymbol(QwtSymbol.Ellipse, - QBrush(), - QPen(Qt.yellow), - QSize(7, 7))) + self.curveL.setSymbol( + QwtSymbol(QwtSymbol.Ellipse, QBrush(), QPen(Qt.yellow), QSize(7, 7)) + ) self.curveR.setPen(QPen(Qt.red)) self.curveL.setPen(QPen(Qt.blue)) @@ -54,50 +61,46 @@ def __init__(self, *args): self.setAxisTitle(QwtPlot.xBottom, "Time (seconds)") self.setAxisTitle(QwtPlot.yLeft, "Values") - - self.startTimer(50) + + self.startTimer(10 if unattended else 50) self.phase = 0.0 def alignScales(self): self.canvas().setFrameStyle(QFrame.Box | QFrame.Plain) self.canvas().setLineWidth(1) - for i in range(QwtPlot.axisCnt): - scaleWidget = self.axisWidget(i) + for axis_id in QwtPlot.AXES: + scaleWidget = self.axisWidget(axis_id) if scaleWidget: scaleWidget.setMargin(0) - scaleDraw = self.axisScaleDraw(i) + scaleDraw = self.axisScaleDraw(axis_id) if scaleDraw: scaleDraw.enableComponent(QwtAbstractScaleDraw.Backbone, False) - + def timerEvent(self, e): if self.phase > np.pi - 0.0001: self.phase = 0.0 # y moves from left to right: # shift y array right and assign new value y[0] - self.y = np.concatenate((self.y[:1], self.y[:-1]), 1) - self.y[0] = np.sin(self.phase) * (-1.0 + 2.0*random.random()) - + self.y = np.concatenate((self.y[:1], self.y[:-1])) + self.y[0] = np.sin(self.phase) * (-1.0 + 2.0 * random.random()) + # z moves from right to left: # Shift z array left and assign new value to z[n-1]. - self.z = np.concatenate((self.z[1:], self.z[:1]), 1) - self.z[-1] = 0.8 - (2.0 * self.phase/np.pi) + 0.4*random.random() + self.z = np.concatenate((self.z[1:], self.z[:1])) + self.z[-1] = 0.8 - (2.0 * self.phase / np.pi) + 0.4 * random.random() self.curveR.setData(self.x, self.y) self.curveL.setData(self.x, self.z) self.replot() - self.phase += np.pi*0.02 + self.phase += np.pi * 0.02 -def make(): - demo = DataPlot() - demo.resize(500, 300) - demo.show() - return demo +def test_data(): + """Data Test""" + utils.test_widget(DataPlot, size=(500, 300)) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) +if __name__ == "__main__": + test_data() diff --git a/examples/ErrorBarDemo.py b/qwt/tests/test_errorbar.py similarity index 56% rename from examples/ErrorBarDemo.py rename to qwt/tests/test_errorbar.py index 9e7d11e..55f2fcc 100644 --- a/examples/ErrorBarDemo.py +++ b/qwt/tests/test_errorbar.py @@ -2,23 +2,34 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) -import sys +SHOW = True # Show test in GUI-based test launcher + import numpy as np +from qtpy.QtCore import QLineF, QRectF, QSize, Qt +from qtpy.QtGui import QBrush, QPen -from qwt.qt.QtGui import QApplication, QPen, QBrush -from qwt.qt.QtCore import QSize, QRectF, QLineF -from qwt.qt.QtCore import Qt -from qwt import QwtPlot, QwtSymbol, QwtPlotGrid, QwtPlotCurve, QwtText +from qwt import QwtPlot, QwtPlotCurve, QwtPlotGrid, QwtSymbol +from qwt.tests import utils class ErrorBarPlotCurve(QwtPlotCurve): - def __init__(self, x=[], y=[], dx=None, dy=None, curvePen=None, - curveStyle=None, curveSymbol=None, errorPen=None, - errorCap=0, errorOnTop=False): + def __init__( + self, + x=[], + y=[], + dx=None, + dy=None, + curvePen=None, + curveStyle=None, + curveSymbol=None, + errorPen=None, + errorCap=0, + errorOnTop=False, + ): """A curve of x versus y data with error bars in dx and dy. Horizontal error bars are plotted if dx is not None. @@ -32,22 +43,22 @@ def __init__(self, x=[], y=[], dx=None, dy=None, curvePen=None, (x-dx[0], x+dx[1]) or (y-dy[0], y+dy[1]). curvePen is the pen used to plot the curve - + curveStyle is the style used to plot the curve - + curveSymbol is the symbol used to plot the symbols - + errorPen is the pen used to plot the error bars - + errorCap is the size of the error bar caps - + errorOnTop is a boolean: - if True, plot the error bars on top of the curve, - if False, plot the curve on top of the error bars. """ QwtPlotCurve.__init__(self) - + if curvePen is None: curvePen = QPen(Qt.NoPen) if curveStyle is None: @@ -56,7 +67,7 @@ def __init__(self, x=[], y=[], dx=None, dy=None, curvePen=None, curveSymbol = QwtSymbol() if errorPen is None: errorPen = QPen(Qt.NoPen) - + self.setData(x, y, dx, dy) self.setPen(curvePen) self.setStyle(curveStyle) @@ -81,7 +92,7 @@ def setData(self, *args): if len(args) == 1: QwtPlotCurve.setData(self, *args) return - + dx = None dy = None x, y = args[:2] @@ -89,36 +100,35 @@ def setData(self, *args): dx = args[2] if len(args) > 3: dy = args[3] - - self.__x = np.asarray(x, np.float) + + self.__x = np.asarray(x, float) if len(self.__x.shape) != 1: - raise RuntimeError('len(asarray(x).shape) != 1') + raise RuntimeError("len(asarray(x).shape) != 1") - self.__y = np.asarray(y, np.float) + self.__y = np.asarray(y, float) if len(self.__y.shape) != 1: - raise RuntimeError('len(asarray(y).shape) != 1') + raise RuntimeError("len(asarray(y).shape) != 1") if len(self.__x) != len(self.__y): - raise RuntimeError('len(asarray(x)) != len(asarray(y))') + raise RuntimeError("len(asarray(x)) != len(asarray(y))") if dx is None: self.__dx = None else: - self.__dx = np.asarray(dx, np.float) + self.__dx = np.asarray(dx, float) if len(self.__dx.shape) not in [0, 1, 2]: - raise RuntimeError('len(asarray(dx).shape) not in [0, 1, 2]') - + raise RuntimeError("len(asarray(dx).shape) not in [0, 1, 2]") + if dy is None: self.__dy = dy else: - self.__dy = np.asarray(dy, np.float) + self.__dy = np.asarray(dy, float) if len(self.__dy.shape) not in [0, 1, 2]: - raise RuntimeError('len(asarray(dy).shape) not in [0, 1, 2]') - + raise RuntimeError("len(asarray(dy).shape) not in [0, 1, 2]") + QwtPlotCurve.setData(self, self.__x, self.__y) - + def boundingRect(self): - """Return the bounding rectangle of the data, error bars included. - """ + """Return the bounding rectangle of the data, error bars included.""" if self.__dx is None: xmin = min(self.__x) xmax = max(self.__x) @@ -139,9 +149,9 @@ def boundingRect(self): ymin = min(self.__y - self.__dy[0]) ymax = max(self.__y + self.__dy[1]) - return QRectF(xmin, ymin, xmax-xmin, ymax-ymin) + return QRectF(xmin, ymin, xmax - xmin, ymax - ymin) - def drawSeries(self, painter, xMap, yMap, canvasRect, first, last = -1): + def drawSeries(self, painter, xMap, yMap, canvasRect, first, last=-1): """Draw an interval of the curve, including the error bars painter is the QPainter used to draw the curve @@ -149,7 +159,7 @@ def drawSeries(self, painter, xMap, yMap, canvasRect, first, last = -1): xMap is the QwtDiMap used to map x-values to pixels yMap is the QwtDiMap used to map y-values to pixels - + first is the index of the first data point to draw last is the index of the last data point to draw. If last < 0, last @@ -160,8 +170,7 @@ def drawSeries(self, painter, xMap, yMap, canvasRect, first, last = -1): last = self.dataSize() - 1 if self.errorOnTop: - QwtPlotCurve.drawSeries(self, painter, xMap, yMap, - canvasRect, first, last) + QwtPlotCurve.drawSeries(self, painter, xMap, yMap, canvasRect, first, last) # draw the error bars painter.save() @@ -171,33 +180,50 @@ def drawSeries(self, painter, xMap, yMap, canvasRect, first, last = -1): if self.__dx is not None: # draw the bars if len(self.__dx.shape) in [0, 1]: - xmin = (self.__x - self.__dx) - xmax = (self.__x + self.__dx) + xmin = self.__x - self.__dx + xmax = self.__x + self.__dx else: - xmin = (self.__x - self.__dx[0]) - xmax = (self.__x + self.__dx[1]) + xmin = self.__x - self.__dx[0] + xmax = self.__x + self.__dx[1] y = self.__y n, i = len(y), 0 lines = [] while i < n: yi = yMap.transform(y[i]) - lines.append(QLineF(xMap.transform(xmin[i]), yi, - xMap.transform(xmax[i]), yi)) + lines.append( + QLineF(xMap.transform(xmin[i]), yi, xMap.transform(xmax[i]), yi) + ) i += 1 painter.drawLines(lines) if self.errorCap > 0: # draw the caps - cap = self.errorCap/2 - n, i, = len(y), 0 + cap = self.errorCap / 2 + ( + n, + i, + ) = ( + len(y), + 0, + ) lines = [] while i < n: yi = yMap.transform(y[i]) lines.append( - QLineF(xMap.transform(xmin[i]), yi - cap, - xMap.transform(xmin[i]), yi + cap)) + QLineF( + xMap.transform(xmin[i]), + yi - cap, + xMap.transform(xmin[i]), + yi + cap, + ) + ) lines.append( - QLineF(xMap.transform(xmax[i]), yi - cap, - xMap.transform(xmax[i]), yi + cap)) + QLineF( + xMap.transform(xmax[i]), + yi - cap, + xMap.transform(xmax[i]), + yi + cap, + ) + ) i += 1 painter.drawLines(lines) @@ -205,83 +231,97 @@ def drawSeries(self, painter, xMap, yMap, canvasRect, first, last = -1): if self.__dy is not None: # draw the bars if len(self.__dy.shape) in [0, 1]: - ymin = (self.__y - self.__dy) - ymax = (self.__y + self.__dy) + ymin = self.__y - self.__dy + ymax = self.__y + self.__dy else: - ymin = (self.__y - self.__dy[0]) - ymax = (self.__y + self.__dy[1]) + ymin = self.__y - self.__dy[0] + ymax = self.__y + self.__dy[1] x = self.__x - n, i, = len(x), 0 + ( + n, + i, + ) = ( + len(x), + 0, + ) lines = [] while i < n: xi = xMap.transform(x[i]) lines.append( - QLineF(xi, yMap.transform(ymin[i]), - xi, yMap.transform(ymax[i]))) + QLineF(xi, yMap.transform(ymin[i]), xi, yMap.transform(ymax[i])) + ) i += 1 painter.drawLines(lines) # draw the caps if self.errorCap > 0: - cap = self.errorCap/2 - n, i, j = len(x), 0, 0 + cap = self.errorCap / 2 + n, i, _j = len(x), 0, 0 lines = [] while i < n: xi = xMap.transform(x[i]) lines.append( - QLineF(xi - cap, yMap.transform(ymin[i]), - xi + cap, yMap.transform(ymin[i]))) + QLineF( + xi - cap, + yMap.transform(ymin[i]), + xi + cap, + yMap.transform(ymin[i]), + ) + ) lines.append( - QLineF(xi - cap, yMap.transform(ymax[i]), - xi + cap, yMap.transform(ymax[i]))) + QLineF( + xi - cap, + yMap.transform(ymax[i]), + xi + cap, + yMap.transform(ymax[i]), + ) + ) i += 1 painter.drawLines(lines) painter.restore() if not self.errorOnTop: - QwtPlotCurve.drawSeries(self, painter, xMap, yMap, - canvasRect, first, last) - - -def make(): - # create a plot with a white canvas - demo = QwtPlot(QwtText("Errorbar Demonstation")) - demo.setCanvasBackground(Qt.white) - demo.plotLayout().setAlignCanvasToScales(True) - - grid = QwtPlotGrid() - grid.attach(demo) - grid.setPen(QPen(Qt.black, 0, Qt.DotLine)) - - # calculate data and errors for a curve with error bars - x = np.arange(0, 10.1, 0.5, np.float) - y = np.sin(x) - dy = 0.2 * abs(y) - # dy = (0.15 * abs(y), 0.25 * abs(y)) # uncomment for asymmetric error bars - dx = 0.2 # all error bars the same size - errorOnTop = False # uncomment to draw the curve on top of the error bars - # errorOnTop = True # uncomment to draw the error bars on top of the curve - curve = ErrorBarPlotCurve( - x = x, - y = y, - dx = dx, - dy = dy, - curvePen = QPen(Qt.black, 2), - curveSymbol = QwtSymbol(QwtSymbol.Ellipse, - QBrush(Qt.red), - QPen(Qt.black, 2), - QSize(9, 9)), - errorPen = QPen(Qt.blue, 2), - errorCap = 10, - errorOnTop = errorOnTop, + QwtPlotCurve.drawSeries(self, painter, xMap, yMap, canvasRect, first, last) + + +class ErrorBarPlot(QwtPlot): + def __init__(self, parent=None, title=None): + super(ErrorBarPlot, self).__init__("Errorbar Demonstation") + self.setCanvasBackground(Qt.white) + self.plotLayout().setAlignCanvasToScales(True) + grid = QwtPlotGrid() + grid.attach(self) + grid.setPen(QPen(Qt.black, 0, Qt.DotLine)) + + # calculate data and errors for a curve with error bars + x = np.arange(0, 10.1, 0.5, float) + y = np.sin(x) + dy = 0.2 * abs(y) + # dy = (0.15 * abs(y), 0.25 * abs(y)) # uncomment for asymmetric error bars + dx = 0.2 # all error bars the same size + errorOnTop = False # uncomment to draw the curve on top of the error bars + # errorOnTop = True # uncomment to draw the error bars on top of the curve + symbol = QwtSymbol( + QwtSymbol.Ellipse, QBrush(Qt.red), QPen(Qt.black, 2), QSize(9, 9) + ) + curve = ErrorBarPlotCurve( + x=x, + y=y, + dx=dx, + dy=dy, + curvePen=QPen(Qt.black, 2), + curveSymbol=symbol, + errorPen=QPen(Qt.blue, 2), + errorCap=10, + errorOnTop=errorOnTop, ) - curve.attach(demo) - demo.resize(640, 480) - demo.show() - return demo + curve.attach(self) + + +def test_errorbar(): + """Errorbar plot example""" + utils.test_widget(ErrorBarPlot, size=(640, 480)) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) +if __name__ == "__main__": + test_errorbar() diff --git a/examples/EventFilterDemo.py b/qwt/tests/test_eventfilter.py similarity index 70% rename from examples/EventFilterDemo.py rename to qwt/tests/test_eventfilter.py index 6982702..64fa1d7 100644 --- a/examples/EventFilterDemo.py +++ b/qwt/tests/test_eventfilter.py @@ -2,66 +2,75 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) -import sys +SHOW = True # Show test in GUI-based test launcher + +import os + import numpy as np +from qtpy.QtCore import QEvent, QObject, QPoint, QRect, QSize, Qt, Signal +from qtpy.QtGui import QBrush, QColor, QPainter, QPen +from qtpy.QtWidgets import QApplication, QMainWindow, QToolBar, QWhatsThis, QWidget + +from qwt import ( + QwtPlot, + QwtPlotCanvas, + QwtPlotCurve, + QwtPlotGrid, + QwtScaleDiv, + QwtScaleDraw, + QwtSymbol, +) +from qwt.tests import utils -from qwt.qt.QtGui import (QApplication, QPen, QBrush, QColor, QWidget, - QMainWindow, QPainter, QPixmap, QToolBar, QWhatsThis) -from qwt.qt.QtCore import QSize, QEvent, Signal, QRect, QObject, Qt, QPoint -from qwt.qt import PYQT5 -from qwt import (QwtPlot, QwtScaleDraw, QwtSymbol, QwtPlotGrid, QwtPlotCurve, - QwtPlotCanvas, QwtScaleDiv) +QT_API = os.environ["QT_API"] class ColorBar(QWidget): - SIG_COLOR_SELECTED = Signal(QColor) - + colorSelected = Signal(QColor) + def __init__(self, orientation, *args): QWidget.__init__(self, *args) self.__orientation = orientation self.__light = QColor(Qt.white) self.__dark = QColor(Qt.black) self.setCursor(Qt.PointingHandCursor) - + def setOrientation(self, orientation): self.__orientation = orientation self.update() - + def orientation(self): return self.__orientation - + def setRange(self, light, dark): self.__light = light self.__dark = dark self.update() - + def setLight(self, color): self.__light = color self.update() - + def setDark(self, color): self.__dark = color self.update() - + def light(self): return self.__light - + def dark(self): return self.__dark - + def mousePressEvent(self, event): if event.button() == Qt.LeftButton: - if PYQT5: - pm = self.grab() - else: - pm = QPixmap.grabWidget(self) + pm = self.grab() color = QColor() color.setRgb(pm.toImage().pixel(event.x(), event.y())) - self.SIG_COLOR_SELECTED.emit(color) + self.colorSelected.emit(color) event.accept() def paintEvent(self, _): @@ -76,23 +85,27 @@ def drawColorBar(self, painter, rect): painter.setClipping(True) painter.fillRect(rect, QBrush(self.__dark)) sectionSize = 2 - if (self.__orientation == Qt.Horizontal): - numIntervals = rect.width()/sectionSize + if self.__orientation == Qt.Horizontal: + numIntervals = rect.width() / sectionSize else: - numIntervals = rect.height()/sectionSize + numIntervals = rect.height() / sectionSize section = QRect() for i in range(int(numIntervals)): if self.__orientation == Qt.Horizontal: - section.setRect(rect.x() + i*sectionSize, rect.y(), - sectionSize, rect.heigh()) + section.setRect( + rect.x() + i * sectionSize, rect.y(), sectionSize, rect.heigh() + ) else: - section.setRect(rect.x(), rect.y() + i*sectionSize, - rect.width(), sectionSize) - ratio = float(i)/float(numIntervals) + section.setRect( + rect.x(), rect.y() + i * sectionSize, rect.width(), sectionSize + ) + ratio = float(i) / float(numIntervals) color = QColor() - color.setHsv(h1 + int(ratio*(h2-h1) + 0.5), - s1 + int(ratio*(s2-s1) + 0.5), - v1 + int(ratio*(v2-v1) + 0.5)) + color.setHsv( + h1 + int(ratio * (h2 - h1) + 0.5), + s1 + int(ratio * (s2 - s1) + 0.5), + v1 + int(ratio * (v2 - v1) + 0.5), + ) painter.fillRect(section, color) painter.restore() @@ -102,21 +115,22 @@ def __init__(self, *args): QwtPlot.__init__(self, *args) self.setTitle("Interactive Plot") - + self.setCanvasColor(Qt.darkCyan) grid = QwtPlotGrid() grid.attach(self) grid.setMajorPen(QPen(Qt.white, 0, Qt.DotLine)) - + self.setAxisScale(QwtPlot.xBottom, 0.0, 100.0) self.setAxisScale(QwtPlot.yLeft, 0.0, 100.0) # Avoid jumping when label with 3 digits # appear/disappear when scrolling vertically scaleDraw = self.axisScaleDraw(QwtPlot.yLeft) - scaleDraw.setMinimumExtent(scaleDraw.extent( - self.axisWidget(QwtPlot.yLeft).font())) + scaleDraw.setMinimumExtent( + scaleDraw.extent(self.axisWidget(QwtPlot.yLeft).font()) + ) self.plotLayout().setAlignCanvasToScales(True) @@ -124,18 +138,17 @@ def __init__(self, *args): self.__insertCurve(Qt.Vertical, Qt.magenta, 70.0) self.__insertCurve(Qt.Horizontal, Qt.yellow, 30.0) self.__insertCurve(Qt.Horizontal, Qt.white, 70.0) - + self.replot() scaleWidget = self.axisWidget(QwtPlot.yLeft) scaleWidget.setMargin(10) self.__colorBar = ColorBar(Qt.Vertical, scaleWidget) - self.__colorBar.setRange( - QColor(Qt.red), QColor(Qt.darkBlue)) + self.__colorBar.setRange(QColor(Qt.red), QColor(Qt.darkBlue)) self.__colorBar.setFocusPolicy(Qt.TabFocus) - self.__colorBar.SIG_COLOR_SELECTED.connect(self.setCanvasColor) - + self.__colorBar.colorSelected.connect(self.setCanvasColor) + # we need the resize events, to lay out the color bar scaleWidget.installEventFilter(self) @@ -143,12 +156,15 @@ def __init__(self, *args): self.canvas().installEventFilter(self) scaleWidget.setWhatsThis( - 'Selecting a value at the scale will insert a new curve.') + "Selecting a value at the scale will insert a new curve." + ) self.__colorBar.setWhatsThis( - 'Selecting a color will change the background of the plot.') + "Selecting a color will change the background of the plot." + ) self.axisWidget(QwtPlot.xBottom).setWhatsThis( - 'Selecting a value at the scale will insert a new curve.') - + "Selecting a value at the scale will insert a new curve." + ) + def setCanvasColor(self, color): self.setCanvasBackground(color) self.replot() @@ -157,18 +173,17 @@ def scrollLeftAxis(self, value): self.setAxisScale(QwtPlot.yLeft, value, value + 100) self.replot() - def eventFilter(self, object, event): + def eventFilter(self, obj, event): if event.type() == QEvent.Resize: size = event.size() - if object == self.axisWidget(QwtPlot.yLeft): + if obj == self.axisWidget(QwtPlot.yLeft): margin = 2 - x = size.width() - object.margin() + margin - w = object.margin() - 2 * margin - y = object.startBorderDist() - h = (size.height() - - object.startBorderDist() - object.endBorderDist()) + x = size.width() - obj.margin() + margin + w = obj.margin() - 2 * margin + y = int(obj.startBorderDist()) + h = int(size.height() - obj.startBorderDist() - obj.endBorderDist()) self.__colorBar.setGeometry(x, y, w, h) - return QwtPlot.eventFilter(self, object, event) + return QwtPlot.eventFilter(self, obj, event) def insertCurve(self, axis, base): if axis == QwtPlot.yLeft or axis == QwtPlot.yRight: @@ -182,12 +197,11 @@ def __insertCurve(self, orientation, color, base): curve = QwtPlotCurve() curve.attach(self) curve.setPen(QPen(color)) - curve.setSymbol(QwtSymbol(QwtSymbol.Ellipse, - QBrush(Qt.gray), - QPen(color), - QSize(8, 8))) - fixed = base*np.ones(10, np.float) - changing = np.arange(0, 95.0, 10.0, np.float) + 5.0 + curve.setSymbol( + QwtSymbol(QwtSymbol.Ellipse, QBrush(Qt.gray), QPen(color), QSize(8, 8)) + ) + fixed = base * np.ones(10, float) + changing = np.arange(0, 95.0, 10.0, float) + 5.0 if orientation == Qt.Horizontal: curve.setData(changing, fixed) else: @@ -201,22 +215,22 @@ def __init__(self, plot): self.__selectedPoint = -1 self.__plot = plot canvas = plot.canvas() - canvas.installEventFilter(self) + canvas.installEventFilter(self) # We want the focus, but no focus rect. # The selected point will be highlighted instead. canvas.setFocusPolicy(Qt.StrongFocus) canvas.setCursor(Qt.PointingHandCursor) canvas.setFocusIndicator(QwtPlotCanvas.ItemFocusIndicator) - canvas.setFocus() + canvas.setFocus() canvas.setWhatsThis( - 'All points can be moved using the left mouse button ' - 'or with these keys:\n\n' - '- Up: Select next curve\n' - '- Down: Select previous curve\n' + "All points can be moved using the left mouse button " + "or with these keys:\n\n" + "- Up: Select next curve\n" + "- Down: Select previous curve\n" '- Left, "-": Select next point\n' '- Right, "+": Select previous point\n' - '- 7, 8, 9, 4, 6, 1, 2, 3: Move selected point' - ) + "- 7, 8, 9, 4, 6, 1, 2, 3: Move selected point" + ) self.__shiftCurveCursor(True) def event(self, event): @@ -224,19 +238,22 @@ def event(self, event): self.__showCursor(True) return True return QObject.event(self, event) - + def eventFilter(self, object, event): if event.type() == QEvent.FocusIn: self.__showCursor(True) if event.type() == QEvent.FocusOut: - self.__showCursor(False) + try: + self.__showCursor(False) + except RuntimeError: + pass # ignore error when closing the application if event.type() == QEvent.Paint: QApplication.postEvent(self, QEvent(QEvent.User)) elif event.type() == QEvent.MouseButtonPress: - self.__select(event.pos()) + self.__select(event.position()) return True elif event.type() == QEvent.MouseMove: - self.__move(event.pos()) + self.__move(event.position()) return True if event.type() == QEvent.KeyPress: delta = 5 @@ -275,7 +292,7 @@ def eventFilter(self, object, event): self.__moveBy(0, -delta) elif key == Qt.Key_9: self.__moveBy(delta, -delta) - return QwtPlot.eventFilter(self.__plot, object, event) + return False def __select(self, pos): found, distance, point = None, 1e100, -1 @@ -309,8 +326,8 @@ def __move(self, pos): curve = self.__selectedCurve if not curve: return - xData = np.zeros(curve.dataSize(), np.float) - yData = np.zeros(curve.dataSize(), np.float) + xData = np.zeros(curve.dataSize(), float) + yData = np.zeros(curve.dataSize(), float) for i in range(curve.dataSize()): if i == self.__selectedPoint: xData[i] = self.__plot.invTransform(curve.xAxis(), pos.x()) @@ -318,7 +335,7 @@ def __move(self, pos): else: s = curve.sample(i) xData[i] = s.x() - yData[i] = s.y() + yData[i] = s.y() curve.setData(xData, yData) self.__showCursor(True) self.__plot.replot() @@ -334,10 +351,11 @@ def __showCursor(self, showIt): curve.directPaint(self.__selectedPoint, self.__selectedPoint) if showIt: symbol.setBrush(brush) - + def __shiftCurveCursor(self, up): - curves = [curve for curve in self.__plot.itemList() - if isinstance(curve, QwtPlotCurve)] + curves = [ + curve for curve in self.__plot.itemList() if isinstance(curve, QwtPlotCurve) + ] if not curves: return if self.__selectedCurve in curves: @@ -374,26 +392,30 @@ def __shiftPointCursor(self, up): class ScalePicker(QObject): - SIG_CLICKED = Signal(int, float) - + clicked = Signal(int, float) + def __init__(self, plot): QObject.__init__(self, plot) - for i in range(QwtPlot.axisCnt): - scaleWidget = plot.axisWidget(i) + for axis_id in QwtPlot.AXES: + scaleWidget = plot.axisWidget(axis_id) if scaleWidget: scaleWidget.installEventFilter(self) def eventFilter(self, object, event): - if (event.type() == QEvent.MouseButtonPress): - self.__mouseClicked(object, event.pos()) + if event.type() == QEvent.MouseButtonPress: + self.__mouseClicked(object, event.position()) return True return QObject.eventFilter(self, object, event) def __mouseClicked(self, scale, pos): rect = self.__scaleRect(scale) margin = 10 - rect.setRect(rect.x() - margin, rect.y() - margin, - rect.width() + 2 * margin, rect.height() + 2 * margin) + rect.setRect( + rect.x() - margin, + rect.y() - margin, + rect.width() + 2 * margin, + rect.height() + 2 * margin, + ) if rect.contains(pos): value = 0.0 axis = -1 @@ -410,48 +432,51 @@ def __mouseClicked(self, scale, pos): elif scale.alignment() == QwtScaleDraw.TopScale: value = sd.scaleMap().invTransform(pos.x()) axis = QwtPlot.xBottom - self.SIG_CLICKED.emit(axis, value) - + self.clicked.emit(axis, value) + def __scaleRect(self, scale): bld = scale.margin() mjt = scale.scaleDraw().tickLength(QwtScaleDiv.MajorTick) sbd = scale.startBorderDist() ebd = scale.endBorderDist() if scale.alignment() == QwtScaleDraw.LeftScale: - return QRect(scale.width() - bld - mjt, sbd, - mjt, scale.height() - sbd - ebd) - elif scale.alignment() == QwtScaleDraw.RightScale: - return QRect(bld, sbd,mjt, scale.height() - sbd - ebd) + return QRect( + scale.width() - bld - mjt, sbd, mjt, scale.height() - sbd - ebd + ) + elif scale.alignment() == QwtScaleDraw.RightScale: + return QRect(bld, sbd, mjt, scale.height() - sbd - ebd) elif scale.alignment() == QwtScaleDraw.BottomScale: return QRect(sbd, bld, scale.width() - sbd - ebd, mjt) elif scale.alignment() == QwtScaleDraw.TopScale: - return QRect(sbd, scale.height() - bld - mjt, - scale.width() - sbd - ebd, mjt) + return QRect( + sbd, scale.height() - bld - mjt, scale.width() - sbd - ebd, mjt + ) else: return QRect() -def make(): - demo = QMainWindow() - toolBar = QToolBar(demo) - toolBar.addAction(QWhatsThis.createAction(toolBar)) - demo.addToolBar(toolBar) - plot = Plot(demo) - demo.setCentralWidget(plot) - plot.setWhatsThis( - 'An useless plot to demonstrate how to use event filtering.\n\n' - 'You can click on the color bar, the scales or move the slider.\n' - 'All points can be moved using the mouse or the keyboard.' +class EventFilterWindow(QMainWindow): + def __init__(self, parent=None): + super(EventFilterWindow, self).__init__(parent=parent) + toolBar = QToolBar(self) + toolBar.addAction(QWhatsThis.createAction(toolBar)) + self.addToolBar(toolBar) + plot = Plot() + self.setCentralWidget(plot) + plot.setWhatsThis( + "An useless plot to demonstrate how to use event filtering.\n\n" + "You can click on the color bar, the scales or move the slider.\n" + "All points can be moved using the mouse or the keyboard." ) - CanvasPicker(plot) - scalePicker = ScalePicker(plot) - scalePicker.SIG_CLICKED.connect(plot.insertCurve) - demo.resize(540, 400) - demo.show() - return demo - - -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) + CanvasPicker(plot) + scalePicker = ScalePicker(plot) + scalePicker.clicked.connect(plot.insertCurve) + + +def test_eventfilter(): + """Event filter example""" + utils.test_widget(EventFilterWindow, size=(540, 400)) + + +if __name__ == "__main__": + test_eventfilter() diff --git a/qwt/tests/test_highdpi.py b/qwt/tests/test_highdpi.py new file mode 100644 index 0000000..44f951c --- /dev/null +++ b/qwt/tests/test_highdpi.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +import os + +import pytest + +from qwt.tests import utils +from qwt.tests.test_simple import SimplePlot + + +class HighDPIPlot(SimplePlot): + NUM_POINTS = 5000000 # 5 million points needed to test high DPI support + + +@pytest.mark.skip(reason="This test is not relevant for the automated test suite") +def test_highdpi(): + """Test high DPI support""" + + # Performance should be the same with "1" and "2" scale factors: + # (as of today, this is not the case, but it has to be fixed in the future: + # https://github.com/PlotPyStack/PythonQwt/issues/83) + os.environ["QT_SCALE_FACTOR"] = "2" + + utils.test_widget(HighDPIPlot, (800, 480)) + + +if __name__ == "__main__": + test_highdpi() diff --git a/qwt/tests/test_image.py b/qwt/tests/test_image.py new file mode 100644 index 0000000..b0eebfd --- /dev/null +++ b/qwt/tests/test_image.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +import numpy as np +from qtpy.QtCore import Qt +from qtpy.QtGui import QPen, qRgb + +from qwt import ( + QwtInterval, + QwtLegend, + QwtLegendData, + QwtLinearColorMap, + QwtPlot, + QwtPlotCurve, + QwtPlotGrid, + QwtPlotItem, + QwtPlotMarker, + QwtScaleMap, + toQImage, +) +from qwt.tests import utils + + +def bytescale(data, cmin=None, cmax=None, high=255, low=0): + if (hasattr(data, "dtype") and data.dtype.char == np.uint8) or ( + hasattr(data, "typecode") and data.typecode == np.uint8 + ): + return data + high = high - low + if cmin is None: + cmin = min(np.ravel(data)) + if cmax is None: + cmax = max(np.ravel(data)) + scale = high * 1.0 / (cmax - cmin or 1) + bytedata = ((data * 1.0 - cmin) * scale + 0.4999).astype(np.uint8) + return bytedata + np.asarray(low).astype(np.uint8) + + +def linearX(nx, ny): + return np.repeat(np.arange(nx, typecode=np.float32)[:, np.newaxis], ny, -1) + + +def linearY(nx, ny): + return np.repeat(np.arange(ny, typecode=np.float32)[np.newaxis, :], nx, 0) + + +def square(n, min, max): + t = np.arange(min, max, float(max - min) / (n - 1)) + # return outer(cos(t), sin(t)) + return np.cos(t) * np.sin(t)[:, np.newaxis] + + +class PlotImage(QwtPlotItem): + def __init__(self, title=""): + QwtPlotItem.__init__(self) + self.setTitle(title) + self.setItemAttribute(QwtPlotItem.Legend) + self.xyzs = None + + def setData(self, xyzs, xRange=None, yRange=None): + self.xyzs = xyzs + shape = xyzs.shape + if not xRange: + xRange = (0, shape[0]) + if not yRange: + yRange = (0, shape[1]) + + self.xMap = QwtScaleMap(0, xyzs.shape[0], *xRange) + self.plot().setAxisScale(QwtPlot.xBottom, *xRange) + self.yMap = QwtScaleMap(0, xyzs.shape[1], *yRange) + self.plot().setAxisScale(QwtPlot.yLeft, *yRange) + + self.image = toQImage(bytescale(self.xyzs)).mirrored(False, True) + for i in range(0, 256): + self.image.setColor(i, qRgb(i, 0, 255 - i)) + + def updateLegend(self, legend, data): + QwtPlotItem.updateLegend(self, legend, data) + legend.find(self).setText(self.title()) + + def draw(self, painter, xMap, yMap, rect): + """Paint image zoomed to xMap, yMap + + Calculate (x1, y1, x2, y2) so that it contains at least 1 pixel, + and copy the visible region to scale it to the canvas. + """ + assert isinstance(self.plot(), QwtPlot) + + # calculate y1, y2 + # the scanline order (index y) is inverted with respect to the y-axis + y1 = y2 = self.image.height() + y1 *= self.yMap.s2() - yMap.s2() + y1 /= self.yMap.s2() - self.yMap.s1() + y1 = max(0, int(y1 - 0.5)) + y2 *= self.yMap.s2() - yMap.s1() + y2 /= self.yMap.s2() - self.yMap.s1() + y2 = min(self.image.height(), int(y2 + 0.5)) + # calculate x1, x2 -- the pixel order (index x) is normal + x1 = x2 = self.image.width() + x1 *= xMap.s1() - self.xMap.s1() + x1 /= self.xMap.s2() - self.xMap.s1() + x1 = max(0, int(x1 - 0.5)) + x2 *= xMap.s2() - self.xMap.s1() + x2 /= self.xMap.s2() - self.xMap.s1() + x2 = min(self.image.width(), int(x2 + 0.5)) + # copy + image = self.image.copy(x1, y1, x2 - x1, y2 - y1) + # zoom + image = image.scaled( + int(xMap.p2() - xMap.p1() + 1), int(yMap.p1() - yMap.p2() + 1) + ) + # draw + painter.drawImage(int(xMap.p1()), int(yMap.p2()), image) + + +class ImagePlot(QwtPlot): + def __init__(self, *args): + QwtPlot.__init__(self, *args) + # set plot title + self.setTitle("ImagePlot") + # set plot layout + self.plotLayout().setCanvasMargin(0) + self.plotLayout().setAlignCanvasToScales(True) + # set legend + legend = QwtLegend() + legend.setDefaultItemMode(QwtLegendData.Clickable) + self.insertLegend(legend, QwtPlot.RightLegend) + # set axis titles + self.setAxisTitle(QwtPlot.xBottom, "time (s)") + self.setAxisTitle(QwtPlot.yLeft, "frequency (Hz)") + + colorMap = QwtLinearColorMap(Qt.blue, Qt.red) + interval = QwtInterval(-1, 1) + self.enableAxis(QwtPlot.yRight) + self.setAxisScale(QwtPlot.yRight, -1, 1) + self.axisWidget(QwtPlot.yRight).setColorBarEnabled(True) + self.axisWidget(QwtPlot.yRight).setColorMap(interval, colorMap) + + # calculate 3 NumPy arrays + x = np.arange(-2 * np.pi, 2 * np.pi, 0.01) + y = np.pi * np.sin(x) + z = 4 * np.pi * np.cos(x) * np.cos(x) * np.sin(x) + # attach a curve + QwtPlotCurve.make( + x, y, title="y = pi*sin(x)", linecolor=Qt.green, linewidth=2, plot=self + ) + # attach another curve + QwtPlotCurve.make( + x, z, title="y = 4*pi*sin(x)*cos(x)**2", linewidth=2, plot=self + ) + # attach a grid + grid = QwtPlotGrid() + grid.attach(self) + grid.setPen(QPen(Qt.black, 0, Qt.DotLine)) + # attach a horizontal marker at y = 0 + QwtPlotMarker.make( + label="y = 0", + linestyle=QwtPlotMarker.HLine, + align=Qt.AlignRight | Qt.AlignTop, + plot=self, + ) + # attach a vertical marker at x = pi + QwtPlotMarker.make( + np.pi, + 0.0, + label="x = pi", + linestyle=QwtPlotMarker.VLine, + align=Qt.AlignRight | Qt.AlignBottom, + plot=self, + ) + # attach a plot image + plotImage = PlotImage("Image") + plotImage.attach(self) + plotImage.setData( + square(512, -2 * np.pi, 2 * np.pi), + (-2 * np.pi, 2 * np.pi), + (-2 * np.pi, 2 * np.pi), + ) + + legend.clicked.connect(self.toggleVisibility) + + # replot + self.replot() + + def toggleVisibility(self, plotItem, idx): + """Toggle the visibility of a plot item""" + plotItem.setVisible(not plotItem.isVisible()) + self.replot() + + +def test_image(): + """Image plot test""" + utils.test_widget(ImagePlot, size=(600, 400)) + + +if __name__ == "__main__": + test_image() diff --git a/qwt/tests/test_loadtest.py b/qwt/tests/test_loadtest.py new file mode 100644 index 0000000..1576016 --- /dev/null +++ b/qwt/tests/test_loadtest.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015-2021 Pierre Raybaut +# (see LICENSE file for more details) + +"""Load test""" + +SHOW = True # Show test in GUI-based test launcher + +import time + +import numpy as np + +# Local imports +from qwt.tests import test_curvebenchmark1 as cb +from qwt.tests import utils + +NCOLS, NROWS = 6, 5 +NPLOTS = NCOLS * NROWS * 5 * 3 + + +class LTWidget(cb.BMWidget): + def params(self, *args, **kwargs): + return tuple([("Lines", None)] * NCOLS * NROWS) + + +class LoadTest(cb.CurveBenchmark1): + TITLE = "Load test [%d plots]" % NPLOTS + SIZE = (1600, 700) + + def __init__(self, max_n=100, parent=None, unattended=False, **kwargs): + super(LoadTest, self).__init__( + max_n=max_n, parent=parent, unattended=unattended, **kwargs + ) + + def run_benchmark(self, max_n, unattended, **kwargs): + points, symbols = 100, False + iterator = range(0, 1) if unattended else range(int(NPLOTS / (NCOLS * NROWS))) + for _i_page in iterator: + t0 = time.time() + symtext = "with%s symbols" % ("" if symbols else "out") + widget = LTWidget(NCOLS, points, symbols, **kwargs) + title = "%d points" % points + description = "%d plots with %d curves of %d points, %s" % ( + widget.plot_nb, + widget.curve_nb, + points, + symtext, + ) + self.process_iteration(title, description, widget, t0) + print("") + time_str = "Average elapsed time: %d ms" % np.mean(self.durations) + print("[%s] %s" % (utils.get_lib_versions(), time_str)) + + +def test_loadtest(): + """Load test""" + utils.test_widget(LoadTest, options=False) + + +if __name__ == "__main__": + test_loadtest() diff --git a/qwt/tests/test_logcurve.py b/qwt/tests/test_logcurve.py new file mode 100644 index 0000000..be18b17 --- /dev/null +++ b/qwt/tests/test_logcurve.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +import numpy as np + +np.seterr(all="raise") + +from qtpy.QtCore import Qt + +from qwt import QwtLogScaleEngine, QwtPlot, QwtPlotCurve +from qwt.tests import utils + + +class LogCurvePlot(QwtPlot): + def __init__(self): + super(LogCurvePlot, self).__init__( + "LogCurveDemo.py (or how to handle -inf values)" + ) + self.enableAxis(QwtPlot.xBottom) + self.setAxisScaleEngine(QwtPlot.yLeft, QwtLogScaleEngine()) + x = np.arange(0.0, 10.0, 0.1) + y = 10 * np.cos(x) ** 2 - 0.1 + QwtPlotCurve.make(x, y, linecolor=Qt.magenta, plot=self, antialiased=True) + self.replot() + + +def test_logcurve(): + """Log curve demo""" + utils.test_widget(LogCurvePlot, size=(800, 500)) + + +if __name__ == "__main__": + test_logcurve() diff --git a/examples/MapDemo.py b/qwt/tests/test_mapdemo.py similarity index 60% rename from examples/MapDemo.py rename to qwt/tests/test_mapdemo.py index 41b82e6..720790b 100644 --- a/examples/MapDemo.py +++ b/qwt/tests/test_mapdemo.py @@ -2,31 +2,34 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) +SHOW = True # Show test in GUI-based test launcher + import random -import sys import time + import numpy as np +from qtpy.QtCore import QSize, Qt +from qtpy.QtGui import QBrush, QPen +from qtpy.QtWidgets import QMainWindow, QToolBar -from qwt.qt.QtGui import QApplication, QPen, QBrush, QMainWindow, QToolBar -from qwt.qt.QtCore import QSize -from qwt.qt.QtCore import Qt -from qwt import QwtPlot, QwtSymbol, QwtPlotCurve +from qwt import QwtPlot, QwtPlotCurve, QwtSymbol +from qwt.tests import utils def standard_map(x, y, kappa): """provide one interate of the inital conditions (x, y) - for the standard map with parameter kappa.""" - y_new = y-kappa*np.sin(2.0*np.pi*x) - x_new = x+y_new + for the standard map with parameter kappa.""" + y_new = y - kappa * np.sin(2.0 * np.pi * x) + x_new = x + y_new # bring back to [0,1.0]^2 - if( (x_new>1.0) or (x_new<0.0) ): - x_new = x_new - np.floor(x_new) - if( (y_new>1.0) or (y_new<0.0) ): - y_new = y_new - np.floor(y_new) + if (x_new > 1.0) or (x_new < 0.0): + x_new = x_new - np.floor(x_new) + if (y_new > 1.0) or (y_new < 0.0): + y_new = y_new - np.floor(y_new) return x_new, y_new @@ -37,21 +40,20 @@ def __init__(self, *args): self.plot.setTitle("A Simple Map Demonstration") self.plot.setCanvasBackground(Qt.white) self.plot.setAxisTitle(QwtPlot.xBottom, "x") - self.plot.setAxisTitle(QwtPlot.yLeft, "y") + self.plot.setAxisTitle(QwtPlot.yLeft, "y") self.plot.setAxisScale(QwtPlot.xBottom, 0.0, 1.0) self.plot.setAxisScale(QwtPlot.yLeft, 0.0, 1.0) self.setCentralWidget(self.plot) # Initialize map data self.count = self.i = 1000 - self.xs = np.zeros(self.count, np.float) - self.ys = np.zeros(self.count, np.float) + self.xs = np.zeros(self.count, float) + self.ys = np.zeros(self.count, float) self.kappa = 0.2 self.curve = QwtPlotCurve("Map") self.curve.attach(self.plot) - self.curve.setSymbol(QwtSymbol(QwtSymbol.Ellipse, - QBrush(Qt.red), - QPen(Qt.blue), - QSize(5, 5))) + self.curve.setSymbol( + QwtSymbol(QwtSymbol.Ellipse, QBrush(Qt.red), QPen(Qt.blue), QSize(5, 5)) + ) self.curve.setPen(QPen(Qt.cyan)) toolBar = QToolBar(self) self.addToolBar(toolBar) @@ -60,7 +62,7 @@ def __init__(self, *args): self.tid = self.startTimer(self.ticks) self.timer_tic = None self.user_tic = None - self.system_tic = None + self.system_tic = None self.plot.replot() def setTicks(self, ticks): @@ -68,10 +70,6 @@ def setTicks(self, ticks): self.ticks = int(ticks) self.killTimer(self.tid) self.tid = self.startTimer(ticks) - - def resizeEvent(self, event): - self.plot.resize(event.size()) - self.plot.move(0, 0) def moreData(self): if self.i == self.count: @@ -84,29 +82,25 @@ def moreData(self): chunks = [] self.timer_toc = time.time() if self.timer_tic: - chunks.append("wall: %s s." % (self.timer_toc-self.timer_tic)) - print(' '.join(chunks)) + chunks.append("wall: %s s." % (self.timer_toc - self.timer_tic)) + print(" ".join(chunks)) self.timer_tic = self.timer_toc else: self.x, self.y = standard_map(self.x, self.y, self.kappa) self.xs[self.i] = self.x self.ys[self.i] = self.y self.i += 1 - + def timerEvent(self, e): self.moreData() - self.curve.setData(self.xs[:self.i], self.ys[:self.i]) + self.curve.setData(self.xs[: self.i], self.ys[: self.i]) self.plot.replot() -def make(): - demo = MapDemo() - demo.resize(600, 600) - demo.show() - return demo +def test_mapdemo(): + """Map demo""" + utils.test_widget(MapDemo, size=(600, 600)) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) +if __name__ == "__main__": + test_mapdemo() diff --git a/examples/MultiDemo.py b/qwt/tests/test_multidemo.py similarity index 69% rename from examples/MultiDemo.py rename to qwt/tests/test_multidemo.py index c2fe778..951b355 100644 --- a/examples/MultiDemo.py +++ b/qwt/tests/test_multidemo.py @@ -2,44 +2,48 @@ # # Licensed under the terms of the PyQwt License # Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example -# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further -# developments (e.g. ported to python-qwt API) +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) # (see LICENSE file for more details) -import sys +SHOW = True # Show test in GUI-based test launcher + import numpy as np +from qtpy.QtCore import Qt +from qtpy.QtGui import QPen +from qtpy.QtWidgets import QGridLayout, QWidget -from qwt.qt.QtGui import QApplication, QPen, QGridLayout, QWidget -from qwt.qt.QtCore import Qt from qwt import QwtPlot, QwtPlotCurve +from qwt.tests import utils def drange(start, stop, step): start, stop, step = float(start), float(stop), float(step) - size = int(round((stop-start)/step)) - result = [start]*size + size = int(round((stop - start) / step)) + result = [start] * size for i in range(size): - result[i] += i*step + result[i] += i * step return result - + + def lorentzian(x): - return 1.0/(1.0+(x-5.0)**2) + return 1.0 / (1.0 + (x - 5.0) ** 2) class MultiDemo(QWidget): def __init__(self, *args): QWidget.__init__(self, *args) - layout = QGridLayout(self) + layout = QGridLayout(self) # try to create a plot for SciPy arrays # make a curve and copy the data - numpy_curve = QwtPlotCurve('y = lorentzian(x)') + numpy_curve = QwtPlotCurve("y = lorentzian(x)") x = np.arange(0.0, 10.0, 0.01) y = lorentzian(x) numpy_curve.setData(x, y) # here, we know we can plot NumPy arrays numpy_plot = QwtPlot(self) - numpy_plot.setTitle('numpy array') + numpy_plot.setTitle("numpy array") numpy_plot.setCanvasBackground(Qt.white) numpy_plot.plotLayout().setCanvasMargin(0) numpy_plot.plotLayout().setAlignCanvasToScales(True) @@ -51,14 +55,14 @@ def __init__(self, *args): # create a plot widget for lists of Python floats list_plot = QwtPlot(self) - list_plot.setTitle('Python list') + list_plot.setTitle("Python list") list_plot.setCanvasBackground(Qt.white) list_plot.plotLayout().setCanvasMargin(0) list_plot.plotLayout().setAlignCanvasToScales(True) x = drange(0.0, 10.0, 0.01) y = [lorentzian(item) for item in x] # insert a curve, make it red and copy the lists - list_curve = QwtPlotCurve('y = lorentzian(x)') + list_curve = QwtPlotCurve("y = lorentzian(x)") list_curve.attach(list_plot) list_curve.setPen(QPen(Qt.red)) list_curve.setData(x, y) @@ -66,14 +70,10 @@ def __init__(self, *args): list_plot.replot() -def make(): - demo = MultiDemo() - demo.resize(400, 300) - demo.show() - return demo +def test_multidemo(): + """Multiple plot demo""" + utils.test_widget(MultiDemo, size=(400, 300)) -if __name__ == '__main__': - app = QApplication(sys.argv) - demo = make() - sys.exit(app.exec_()) +if __name__ == "__main__": + test_multidemo() diff --git a/qwt/tests/test_relativemargin.py b/qwt/tests/test_relativemargin.py new file mode 100644 index 0000000..932d0fd --- /dev/null +++ b/qwt/tests/test_relativemargin.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +from qtpy import QtWidgets as QW +from qtpy.QtCore import Qt + +import qwt +from qwt.tests import utils + + +class RelativeMarginDemo(QW.QWidget): + def __init__(self, *args): + QW.QWidget.__init__(self, *args) + layout = QW.QGridLayout(self) + x = [1, 2, 3, 4] + y = [1, 500, 1000, 1500] + for i_row, log_scale in enumerate((False, True)): + for i_col, relative_margin in enumerate((0.0, None, 0.2)): + plot = qwt.QwtPlot(self) + qwt.QwtPlotGrid.make( + plot, color=Qt.lightGray, width=0, style=Qt.DotLine + ) + def_margin = plot.axisMargin(qwt.QwtPlot.yLeft) + scale_str = "lin/lin" if not log_scale else "log/lin" + if relative_margin is None: + margin_str = f"default ({def_margin * 100:.0f}%)" + else: + margin_str = f"{relative_margin * 100:.0f}%" + plot.setTitle(f"{scale_str}, margin: {margin_str}") + if relative_margin is not None: + plot.setAxisMargin(qwt.QwtPlot.yLeft, relative_margin) + plot.setAxisMargin(qwt.QwtPlot.xBottom, relative_margin) + color = "red" if i_row == 0 else "blue" + qwt.QwtPlotCurve.make(x, y, "", plot, linecolor=color) + layout.addWidget(plot, i_row, i_col) + if log_scale: + engine = qwt.QwtLogScaleEngine() + plot.setAxisScaleEngine(qwt.QwtPlot.yLeft, engine) + + +def test_relative_margin(): + """Test relative margin.""" + utils.test_widget(RelativeMarginDemo, size=(400, 300), options=False) + + +if __name__ == "__main__": + test_relative_margin() diff --git a/qwt/tests/test_simple.py b/qwt/tests/test_simple.py new file mode 100644 index 0000000..00968ab --- /dev/null +++ b/qwt/tests/test_simple.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the PyQwt License +# Copyright (C) 2003-2009 Gerard Vermeulen, for the original PyQwt example +# Copyright (c) 2015 Pierre Raybaut, for the PyQt5/PySide port and further +# developments (e.g. ported to PythonQwt API) +# (see LICENSE file for more details) + +SHOW = True # Show test in GUI-based test launcher + +import os + +import numpy as np +from qtpy.QtCore import Qt, QTimer + +import qwt +from qwt.tests import utils + +FNAMES = ("test_simple.svg", "test_simple.pdf", "test_simple.png") + + +class SimplePlot(qwt.QwtPlot): + NUM_POINTS = 100 + TEST_EXPORT = True + + def __init__(self): + qwt.QwtPlot.__init__(self) + self.setTitle("Really simple demo") + self.insertLegend(qwt.QwtLegend(), qwt.QwtPlot.RightLegend) + self.setAxisTitle(qwt.QwtPlot.xBottom, "X-axis") + self.setAxisTitle(qwt.QwtPlot.yLeft, "Y-axis") + self.enableAxis(self.xBottom) + self.setCanvasBackground(Qt.white) + + qwt.QwtPlotGrid.make(self, color=Qt.lightGray, width=0, style=Qt.DotLine) + + # insert a few curves + x = np.linspace(0.0, 10.0, self.NUM_POINTS) + qwt.QwtPlotCurve.make(x, np.sin(x), "y = sin(x)", self, linecolor="red") + qwt.QwtPlotCurve.make(x, np.cos(x), "y = cos(x)", self, linecolor="blue") + + # insert a horizontal marker at y = 0 + qwt.QwtPlotMarker.make( + label="y = 0", + align=Qt.AlignRight | Qt.AlignTop, + linestyle=qwt.QwtPlotMarker.HLine, + color="darkGreen", + plot=self, + ) + + # insert a vertical marker at x = 2 pi + qwt.QwtPlotMarker.make( + xvalue=2 * np.pi, + label="x = 2 pi", + align=Qt.AlignRight | Qt.AlignTop, + linestyle=qwt.QwtPlotMarker.VLine, + color="darkGreen", + plot=self, + ) + + if self.TEST_EXPORT and utils.TestEnvironment().unattended: + QTimer.singleShot(0, self.export_to_different_formats) + + def export_to_different_formats(self): + for fname in FNAMES: + self.exportTo(fname) + + +def test_simple(): + """Simple plot example""" + utils.test_widget(SimplePlot, size=(600, 400)) + for fname in FNAMES: + if os.path.isfile(fname): + os.remove(fname) + + +if __name__ == "__main__": + test_simple() diff --git a/qwt/tests/test_stylesheet.py b/qwt/tests/test_stylesheet.py new file mode 100644 index 0000000..5f67acc --- /dev/null +++ b/qwt/tests/test_stylesheet.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- + +SHOW = True # Show test in GUI-based test launcher + +import os + +import numpy as np +import pytest +import qtpy +from qtpy.QtCore import Qt + +import qwt +from qwt.tests import utils + + +class StyleSheetPlot(qwt.QwtPlot): + def __init__(self): + super().__init__() + self.setTitle("Stylesheet test (Issue #63)") + self.setStyleSheet("background-color: #19232D; color: #E0E1E3;") + qwt.QwtPlotGrid.make(self, color=Qt.white, width=0, style=Qt.DotLine) + x = np.arange(-5.0, 5.0, 0.1) + qwt.QwtPlotCurve.make(x, np.sinc(x), "y = sinc(x)", self, linecolor="green") + + +# Skip the test for PySide6 on Linux +@pytest.mark.skipif( + qtpy.API_NAME == "PySide6" and os.name == "posix", + reason="Fails on Linux with PySide6 for unknown reasons", +) +def test_stylesheet(): + """Stylesheet test""" + utils.test_widget(StyleSheetPlot, size=(600, 400)) + + +if __name__ == "__main__": + test_stylesheet() diff --git a/qwt/tests/test_symbols.py b/qwt/tests/test_symbols.py new file mode 100644 index 0000000..36a8852 --- /dev/null +++ b/qwt/tests/test_symbols.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- + +SHOW = True # Show test in GUI-based test launcher + +import os.path as osp + +import numpy as np +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +import qwt +from qwt.tests import utils + + +class BaseSymbolPlot(qwt.QwtPlot): + TITLE = "Base Symbol Example" + SYMBOL_CLASS = qwt.QwtSymbol + + def __init__(self): + super().__init__() + self.setTitle(self.TITLE) + self.setAxisScale(self.yLeft, -20, 20) + self.setAxisScale(self.xBottom, -20, 20) + self.setup_plot() + + def setup_plot(self): + samples = ([-15, 0, 15, -15], [0, 15, 0, 0]) + self.add_curve(self.TITLE, samples, self.SYMBOL_CLASS()) + self.resize(400, 400) + + def add_curve(self, title, samples, symbol=None): + """Add a curve to the plot""" + curve = qwt.QwtPlotCurve(title) + curve.setSamples(*samples) + if symbol is not None: + curve.setSymbol(symbol) + curve.attach(self) + self.replot() + + +class BuiltinSymbolPlot(BaseSymbolPlot): + TITLE = "Built-in Symbol Example" + + def setup_plot(self): + colors = (QC.Qt.red, QC.Qt.green, QC.Qt.blue, QC.Qt.yellow, QC.Qt.magenta) + for index, symbol_name in enumerate( + ( + "Ellipse", + "Rect", + "Diamond", + "Triangle", + "DTriangle", + "UTriangle", + "LTriangle", + "RTriangle", + "Cross", + "XCross", + "HLine", + "VLine", + "Star1", + "Star2", + "Hexagon", + ) + ): + symbol = qwt.symbol.QwtSymbol(getattr(qwt.QwtSymbol, symbol_name)) + symbol.setSize(7, 7) + symbol.setPen(QG.QPen(colors[index % 3])) + symbol.setBrush(QG.QBrush(QG.QColor(colors[index % 3]).lighter(150))) + x = np.linspace(-10, 10, 100) + y = np.sin(x + index * np.pi / 10) + samples = (x, y) + qwt.plot_marker.QwtPlotMarker.make( + xvalue=index * 2 - 10, + yvalue=index * 2 - 10, + label=qwt.text.QwtText.make( + "Marker", + color=QC.Qt.black, + borderradius=2, + brush=QC.Qt.lightGray, + ), + symbol=symbol, + plot=self, + ) + self.add_curve(symbol_name, samples, symbol) + self.setAxisAutoScale(self.yLeft, True) + self.setAxisAutoScale(self.xBottom, True) + + +class CustomGraphicSymbol(qwt.QwtSymbol): + def __init__(self): + super(CustomGraphicSymbol, self).__init__(qwt.QwtSymbol.Graphic) + + # Use a built-in Qt icon as QPixmap for demonstration + icon = QW.QApplication.style().standardIcon(QW.QStyle.SP_FileIcon) + pixmap = icon.pixmap(20, 20) + + # Convert the QPixmap to a QwtGraphic + graphic = qwt.graphic.QwtGraphic() + graphic.setDefaultSize(pixmap.size()) + painter = QG.QPainter(graphic) + painter.drawPixmap(0, 0, pixmap) + painter.end() + + # Set the QwtGraphic as the graphic for the symbol + self.setGraphic(graphic) + + +class GraphicPlot(BaseSymbolPlot): + TITLE = "Custom QwtGraphic Symbol Example" + SYMBOL_CLASS = CustomGraphicSymbol + + +class CustomPixmapSymbol(qwt.QwtSymbol): + def __init__(self): + super(CustomPixmapSymbol, self).__init__(qwt.QwtSymbol.Pixmap) + + # Use a built-in Qt icon as QPixmap for demonstration + icon = QW.QApplication.style().standardIcon(QW.QStyle.SP_DialogYesButton) + pixmap = icon.pixmap(20, 20) + + # Set the QPixmap for the symbol + self.setPixmap(pixmap) + + +class PixmapPlot(BaseSymbolPlot): + TITLE = "Custom QPixmap Symbol Example" + SYMBOL_CLASS = CustomPixmapSymbol + + +class CustomPathSymbol(qwt.QwtSymbol): + def __init__(self): + super(CustomPathSymbol, self).__init__(qwt.QwtSymbol.Path) + + path = QG.QPainterPath() + path.moveTo(0, -10) # Top vertex of the triangle + path.lineTo(-10, 10) # Bottom-left vertex + path.lineTo(10, 10) # Bottom-right vertex + path.closeSubpath() # Close the triangle + + self.setPath(path) + self.setSize(20, 20) + + +class PathPlot(BaseSymbolPlot): + TITLE = "Custom Path Symbol Example" + SYMBOL_CLASS = CustomPathSymbol + + +class CustomSvgSymbol(qwt.QwtSymbol): + FNAME = osp.join(osp.dirname(__file__), "data", "symbol.svg") + + def __init__(self): + super(CustomSvgSymbol, self).__init__(qwt.QwtSymbol.SvgDocument) + + # Load the SVG document from the given file + self.setSvgDocument(self.FNAME) + + +class SvgDocumentPlot(BaseSymbolPlot): + TITLE = "Custom SVG Symbol Example" + SYMBOL_CLASS = CustomSvgSymbol + + +def test_base(): + """Base symbol test""" + utils.test_widget(BaseSymbolPlot, size=(600, 400)) + + +def test_builtin(): + """Built-in symbol test""" + utils.test_widget(BuiltinSymbolPlot, size=(600, 400)) + + +def test_graphic(): + """Graphic symbol test""" + utils.test_widget(GraphicPlot, size=(600, 400)) + + +def test_pixmap(): + """Pixmap test""" + utils.test_widget(PixmapPlot, size=(600, 400)) + + +def test_path(): + """Path symbol test""" + utils.test_widget(PathPlot, size=(600, 400)) + + +def test_svg(): + """SVG test""" + utils.test_widget(SvgDocumentPlot, size=(600, 400)) + + +if __name__ == "__main__": + # test_base() + test_builtin() + # test_graphic() + # test_pixmap() + # test_path() + # test_svg() diff --git a/qwt/tests/test_vertical.py b/qwt/tests/test_vertical.py new file mode 100644 index 0000000..eadb18d --- /dev/null +++ b/qwt/tests/test_vertical.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +"""Simple plot without margins""" + +SHOW = True # Show test in GUI-based test launcher + +import numpy as np +from qtpy.QtCore import Qt +from qtpy.QtGui import QColor, QPalette, QPen + +from qwt import QwtPlot, QwtPlotCurve, QwtPlotMarker, QwtText +from qwt.tests import utils + + +class VerticalPlot(QwtPlot): + def __init__(self, parent=None): + super(VerticalPlot, self).__init__(parent) + self.setWindowTitle("PythonQwt") + self.enableAxis(self.xTop, True) + self.enableAxis(self.yRight, True) + y = np.linspace(0, 10, 500) + curve1 = QwtPlotCurve.make(np.sin(y), y, title="Test Curve 1") + curve2 = QwtPlotCurve.make(y**3, y, title="Test Curve 2") + curve2.setAxes(self.xTop, self.yRight) + + for item, col, xa, ya in ( + (curve1, Qt.green, self.xBottom, self.yLeft), + (curve2, Qt.red, self.xTop, self.yRight), + ): + item.attach(self) + item.setPen(QPen(col)) + for axis_id in xa, ya: + palette = self.axisWidget(axis_id).palette() + palette.setColor(QPalette.WindowText, QColor(col)) + palette.setColor(QPalette.Text, QColor(col)) + self.axisWidget(axis_id).setPalette(palette) + ticks_font = self.axisFont(axis_id) + self.setAxisFont(axis_id, ticks_font) + + self.marker = QwtPlotMarker.make(0, 5, plot=self) + + def resizeEvent(self, event): + super(VerticalPlot, self).resizeEvent(event) + self.show_layout_details() + + def show_layout_details(self): + text = ( + "plotLayout().canvasRect():\n%r\n\n" + "canvas().geometry():\n%r\n\n" + "plotLayout().scaleRect(QwtPlot.yLeft):\n%r\n\n" + "axisWidget(QwtPlot.yLeft).geometry():\n%r\n\n" + "plotLayout().scaleRect(QwtPlot.yRight):\n%r\n\n" + "axisWidget(QwtPlot.yRight).geometry():\n%r\n\n" + "plotLayout().scaleRect(QwtPlot.xBottom):\n%r\n\n" + "axisWidget(QwtPlot.xBottom).geometry():\n%r\n\n" + "plotLayout().scaleRect(QwtPlot.xTop):\n%r\n\n" + "axisWidget(QwtPlot.xTop).geometry():\n%r\n\n" + % ( + self.plotLayout().canvasRect().getCoords(), + self.canvas().geometry().getCoords(), + self.plotLayout().scaleRect(QwtPlot.yLeft).getCoords(), + self.axisWidget(QwtPlot.yLeft).geometry().getCoords(), + self.plotLayout().scaleRect(QwtPlot.yRight).getCoords(), + self.axisWidget(QwtPlot.yRight).geometry().getCoords(), + self.plotLayout().scaleRect(QwtPlot.xBottom).getCoords(), + self.axisWidget(QwtPlot.xBottom).geometry().getCoords(), + self.plotLayout().scaleRect(QwtPlot.xTop).getCoords(), + self.axisWidget(QwtPlot.xTop).geometry().getCoords(), + ) + ) + self.marker.setLabel(QwtText.make(text, family="Courier New", color=Qt.blue)) + + +def test_vertical(): + """Vertical plot example""" + utils.test_widget(VerticalPlot, size=(300, 650)) + + +if __name__ == "__main__": + test_vertical() diff --git a/qwt/tests/utils.py b/qwt/tests/utils.py new file mode 100644 index 0000000..9f4d147 --- /dev/null +++ b/qwt/tests/utils.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the MIT License +# Copyright (c) 2015 Pierre Raybaut +# (see LICENSE file for more details) + +""" +PythonQwt test utilities +------------------------ +""" + +import argparse +import inspect +import os +import os.path as osp +import platform +import subprocess +import sys + +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +import qwt +from qwt import QwtPlot +from qwt import qthelpers as qth + +QT_API = os.environ["QT_API"] + +if QT_API.startswith("pyside"): + from qtpy import PYSIDE_VERSION + + PYTHON_QT_API = "PySide v" + PYSIDE_VERSION +else: + from qtpy import PYQT_VERSION + + PYTHON_QT_API = "PyQt v" + PYQT_VERSION + + +TEST_PATH = osp.abspath(osp.dirname(__file__)) + + +class TestEnvironment: + UNATTENDED_ARG = "unattended" + SCREENSHOTS_ARG = "screenshots" + UNATTENDED_ENV = "PYTHONQWT_UNATTENDED_TESTS" + SCREENSHOTS_ENV = "PYTHONQWT_TAKE_SCREENSHOTS" + + def __init__(self): + self.parse_args() + + @property + def unattended(self): + return os.environ.get(self.UNATTENDED_ENV) is not None + + @property + def screenshots(self): + return os.environ.get(self.SCREENSHOTS_ENV) is not None + + def parse_args(self): + """Parse command line arguments""" + parser = argparse.ArgumentParser(description="Run PythonQwt tests") + parser.add_argument( + "--mode", + choices=[self.UNATTENDED_ARG, self.SCREENSHOTS_ARG], + required=False, + ) + args, _unknown = parser.parse_known_args() + if args.mode is not None: + self.set_env_from_args(args) + + def set_env_from_args(self, args): + """Set appropriate environment variables""" + for name in (self.UNATTENDED_ENV, self.SCREENSHOTS_ENV): + if name in os.environ: + os.environ.pop(name) + if args.mode == self.UNATTENDED_ARG: + os.environ[self.UNATTENDED_ENV] = "1" + if args.mode == self.SCREENSHOTS_ARG: + os.environ[self.SCREENSHOTS_ENV] = os.environ[self.UNATTENDED_ENV] = "1" + + +def get_tests(package): + """Return list of test filenames""" + test_package_name = "%s.tests" % package.__name__ + _temp = __import__(test_package_name) + test_package = sys.modules[test_package_name] + tests = [] + test_path = osp.dirname(osp.realpath(test_package.__file__)) + for fname in sorted( + [ + name + for name in os.listdir(test_path) + if name.endswith((".py", ".pyw")) and not name.startswith(("_", "conftest")) + ] + ): + module_name = osp.splitext(fname)[0] + _temp = __import__(test_package.__name__, fromlist=[module_name]) + module = getattr(_temp, module_name) + if hasattr(module, "SHOW") and module.SHOW: + tests.append(osp.abspath(osp.join(test_path, fname))) + return tests + + +def run_test(fname, wait=True): + """Run test""" + os.environ["PYTHONPATH"] = os.pathsep.join(sys.path) + args = " ".join([sys.executable, '"' + fname + '"']) + if TestEnvironment().unattended: + print(" " + args) + (subprocess.call if wait else subprocess.Popen)(args, shell=True) + + +def run_all_tests(wait=True): + """Run all PythonQwt tests""" + for fname in get_tests(qwt): + run_test(fname, wait=wait) + + +def get_lib_versions(): + """Return string containing Python-Qt versions""" + from qtpy.QtCore import __version__ as qt_version + + return "Python %s, Qt %s, %s on %s" % ( + platform.python_version(), + qt_version, + PYTHON_QT_API, + platform.system(), + ) + + +class TestLauncher(QW.QMainWindow): + """PythonQwt Test Launcher main window""" + + COLUMNS = 5 + + def __init__(self, parent=None): + super(TestLauncher, self).__init__(parent) + self.setObjectName("TestLauncher") + icon = QG.QIcon(osp.join(TEST_PATH, "data", "PythonQwt.svg")) + self.setWindowIcon(icon) + self.setWindowTitle("PythonQwt %s - Test Launcher" % qwt.__version__) + self.setCentralWidget(QW.QWidget()) + self.grid_layout = QW.QGridLayout() + self.centralWidget().setLayout(self.grid_layout) + self.test_nb = None + self.fill_layout() + self.statusBar().show() + self.setStatusTip("Click on any button to run a test") + + def get_std_icon(self, name): + """Return Qt standard icon""" + return self.style().standardIcon(getattr(QW.QStyle, "SP_" + name)) + + def fill_layout(self): + """Fill grid layout""" + for fname in get_tests(qwt): + self.add_test(fname) + toolbar = QW.QToolBar(self) + all_act = QW.QAction(self.get_std_icon("DialogYesButton"), "", self) + all_act.setIconText("Run all tests") + all_act.triggered.connect(lambda checked: run_all_tests(wait=False)) + folder_act = QW.QAction(self.get_std_icon("DirOpenIcon"), "", self) + folder_act.setIconText("Open tests folder") + + def open_test_folder(checked): + return os.startfile(TEST_PATH) + + folder_act.triggered.connect(open_test_folder) + about_act = QW.QAction(self.get_std_icon("FileDialogInfoView"), "", self) + about_act.setIconText("About") + about_act.triggered.connect(self.about) + for action in (all_act, folder_act, None, about_act): + if action is None: + toolbar.addSeparator() + else: + toolbar.addAction(action) + toolbar.setToolButtonStyle(QC.Qt.ToolButtonTextBesideIcon) + self.addToolBar(toolbar) + + def add_test(self, fname): + """Add new test""" + if self.test_nb is None: + self.test_nb = 0 + self.test_nb += 1 + column = (self.test_nb - 1) % self.COLUMNS + row = (self.test_nb - 1) // self.COLUMNS + bname = osp.basename(fname) + button = QW.QToolButton(self) + button.setToolButtonStyle(QC.Qt.ToolButtonTextUnderIcon) + shot = osp.join( + TEST_PATH, "data", bname.replace(".py", ".png").replace("test_", "") + ) + if osp.isfile(shot): + button.setIcon(QG.QIcon(shot)) + else: + button.setIcon(self.get_std_icon("DialogYesButton")) + button.setText(bname) + button.setToolTip(fname) + button.setIconSize(QC.QSize(130, 80)) + button.clicked.connect(lambda checked=None, fname=fname: run_test(fname)) + self.grid_layout.addWidget(button, row, column) + + def about(self): + """About test launcher""" + QW.QMessageBox.about( + self, + "About " + self.windowTitle(), + """%s

Developped by Pierre Raybaut +
Copyright © 2020 Pierre Raybaut +

%s""" + % (self.windowTitle(), get_lib_versions()), + ) + + +class TestOptions(QW.QGroupBox): + """Test options groupbox""" + + def __init__(self, parent=None): + super(TestOptions, self).__init__("Test options", parent) + self.setLayout(QW.QFormLayout()) + self.hide() + + def add_checkbox(self, title, label, slot): + """Add new checkbox to option panel""" + widget = QW.QCheckBox(label, self) + widget.stateChanged.connect(slot) + self.layout().addRow(title, widget) + self.show() + return widget + + +class TestCentralWidget(QW.QWidget): + """Test central widget""" + + def __init__(self, widget_name, parent=None): + super(TestCentralWidget, self).__init__(parent) + self.widget_name = widget_name + self.plots = None + self.setLayout(QW.QVBoxLayout()) + self.options = TestOptions(self) + self.add_widget(self.options) + + def get_widget_of_interest(self): + """Return widget of interest""" + if self.plots is not None and len(self.plots) == 1: + return self.plots[0] + return self.parent() + + def add_widget(self, widget): + """Add new sub-widget""" + self.layout().addWidget(widget) + if isinstance(widget, QwtPlot): + self.plots = [widget] + else: + self.plots = widget.findChildren(QwtPlot) + for index, plot in enumerate(self.plots): + plot_name = plot.objectName() + if not plot_name: + plot_name = "Plot #%d" % (index + 1) + widget = self.options.add_checkbox( + plot_name, "Enable new flat style option", plot.setFlatStyle + ) + widget.setChecked(plot.flatStyle()) + + +def take_screenshot(widget): + """Take screenshot and save it to the data folder""" + bname = (widget.objectName().lower() + ".png").replace("window", "") + bname = bname.replace("plot", "").replace("widget", "") + qth.take_screenshot(widget, osp.join(TEST_PATH, "data", bname), quit=True) + + +def close_widgets_and_quit() -> None: + """Close Qt top level widgets and quit Qt event loop""" + QW.QApplication.processEvents() + for widget in QW.QApplication.instance().topLevelWidgets(): + assert widget.close() + QC.QTimer.singleShot(0, QW.QApplication.instance().quit) + + +def test_widget(widget_class, size=None, title=None, options=True): + """Test widget""" + widget_name = widget_class.__name__ + app = QW.QApplication.instance() + if app is None: + app = QW.QApplication([]) + test_env = TestEnvironment() + if inspect.signature(widget_class).parameters.get("unattended") is None: + widget = widget_class() + else: + widget = widget_class(unattended=test_env.unattended) + window = widget + if options: + if isinstance(widget, QW.QMainWindow): + widget = window.centralWidget() + widget.setParent(None) + else: + window = QW.QMainWindow() + central_widget = TestCentralWidget(widget_name, parent=window) + central_widget.add_widget(widget) + window.setCentralWidget(central_widget) + widget_of_interest = central_widget.get_widget_of_interest() + else: + widget_of_interest = window + widget_of_interest.setObjectName(widget_name) + if title is None: + title = 'Test "%s" - PythonQwt %s' % (widget_name, qwt.__version__) + window.setWindowTitle(title) + if size is not None: + width, height = size + window.resize(width, height) + + window.show() + if test_env.screenshots: + QC.QTimer.singleShot(1000, lambda: take_screenshot(widget_of_interest)) + elif test_env.unattended: + QC.QTimer.singleShot(0, close_widgets_and_quit) + if QT_API == "pyside6": + app.exec() + else: + app.exec_() + return app diff --git a/qwt/text.py b/qwt/text.py index 3f09287..2fb0f9a 100644 --- a/qwt/text.py +++ b/qwt/text.py @@ -5,17 +5,577 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) +""" +Text widgets +------------ + +QwtText +~~~~~~~ + +.. autoclass:: QwtText + :members: + +QwtTextLabel +~~~~~~~~~~~~ + +.. autoclass:: QwtTextLabel + :members: + +Text engines +------------ + +QwtTextEngine +~~~~~~~~~~~~~ + +.. autoclass:: QwtTextEngine + :members: + +QwtPlainTextEngine +~~~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtPlainTextEngine + :members: + +QwtRichTextEngine +~~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtRichTextEngine + :members: +""" + +import math +import os +import struct + +from qtpy.QtCore import QObject, QRectF, QSize, QSizeF, Qt +from qtpy.QtGui import ( + QAbstractTextDocumentLayout, + QColor, + QFont, + QFontInfo, + QFontMetrics, + QFontMetricsF, + QPainter, + QPalette, + QPixmap, + QTextDocument, + QTextOption, + QTransform, +) +from qtpy.QtWidgets import QApplication, QFrame, QSizePolicy, QWidget + from qwt.painter import QwtPainter -from qwt.text_engine import QwtPlainTextEngine, QwtRichTextEngine +from qwt.qthelpers import qcolor_from_str + +QWIDGETSIZE_MAX = (1 << 24) - 1 + +QT_API = os.environ["QT_API"] + + +# Cache Qt alignment flags as plain ints once at import time. On PyQt6 these +# are ``Qt.AlignmentFlag`` enum members and every bitwise test goes through +# ``enum.__and__`` (~6 us each). The test code below combines them in hot +# paths called per-tick / per-label / per-paint event. +def _flag_int(flag): + """Return the integer value of a Qt enum/flag (PyQt5 and PyQt6).""" + try: + return flag.value + except AttributeError: + return int(flag) + + +_ALIGN_LEFT = _flag_int(Qt.AlignLeft) +_ALIGN_RIGHT = _flag_int(Qt.AlignRight) +_ALIGN_TOP = _flag_int(Qt.AlignTop) +_ALIGN_BOTTOM = _flag_int(Qt.AlignBottom) +_ALIGN_HCENTER = _flag_int(Qt.AlignHCenter) +_ALIGN_JUSTIFY = _flag_int(Qt.AlignJustify) +_ALIGN_CENTER = _flag_int(Qt.AlignCenter) + + +def taggedRichText(text, flags): + richText = text + if flags & _ALIGN_JUSTIFY: + richText = '

' + richText + "
" + elif flags & _ALIGN_RIGHT: + richText = '
' + richText + "
" + elif flags & _ALIGN_HCENTER: + richText = '
' + richText + "
" + return richText + + +class QwtRichTextDocument(QTextDocument): + def __init__(self, text, flags, font): + super(QwtRichTextDocument, self).__init__(None) + self.setUndoRedoEnabled(False) + self.setDefaultFont(font) + self.setHtml(text) + + option = self.defaultTextOption() + if flags & Qt.TextWordWrap: + option.setWrapMode(QTextOption.WordWrap) + else: + option.setWrapMode(QTextOption.NoWrap) + + option.setAlignment(flags) + self.setDefaultTextOption(option) + + root = self.rootFrame() + fm = root.frameFormat() + fm.setBorder(0) + fm.setMargin(0) + fm.setPadding(0) + fm.setBottomMargin(0) + fm.setLeftMargin(0) + root.setFrameFormat(fm) + + self.adjustSize() + + +class QwtTextEngine(object): + """ + Abstract base class for rendering text strings + + A text engine is responsible for rendering texts for a + specific text format. They are used by `QwtText` to render a text. + + `QwtPlainTextEngine` and `QwtRichTextEngine` are part of the + `PythonQwt` library. + + .. seealso:: + + :py:meth:`qwt.text.QwtText.setTextEngine()` + """ + + def __init__(self): + pass + + def heightForWidth(self, font, flags, text, width): + """ + Find the height for a given width + + :param QFont font: Font of the text + :param int flags: Bitwise OR of the flags used like in QPainter::drawText + :param str text: Text to be rendered + :param float width: Width + :return: Calculated height + """ + pass + + def textSize(self, font, flags, text): + """ + Returns the size, that is needed to render text + + :param QFont font: Font of the text + :param int flags: Bitwise OR of the flags like in for QPainter::drawText + :param str text: Text to be rendered + :return: Calculated size + """ + pass + + def mightRender(self, text): + """ + Test if a string can be rendered by this text engine + + :param str text: Text to be tested + :return: True, if it can be rendered + """ + pass + + def textMargins(self, font): + """ + Return margins around the texts + + The textSize might include margins around the + text, like QFontMetrics::descent(). In situations + where texts need to be aligned in detail, knowing + these margins might improve the layout calculations. + + :param QFont font: Font of the text + :return: tuple (left, right, top, bottom) representing margins + """ + pass + + def draw(self, painter, rect, flags, text): + """ + Draw the text in a clipping rectangle + + :param QPainter painter: Painter + :param QRectF rect: Clipping rectangle + :param int flags: Bitwise OR of the flags like in for QPainter::drawText() + :param str text: Text to be rendered + """ + pass + + +ASCENTCACHE = {} + +# Module-level cache: ``id(font) -> tuple_key`` (fast path) and +# ``tuple_key -> tuple_key`` (slow path). The tuple key is built from a +# handful of QFont attributes that uniquely identify the *logical* font for +# metrics purposes. Tick-rendering uses very few distinct fonts in practice +# so both dicts stay tiny. +# +# This replaces the previous ``id(font) -> font.key()`` design. Two reasons: +# +# 1. ``QFont.key()`` is a sip dispatch that costs ~3.3 us/call on PyQt5 and +# ~9.3 us/call on PyQt6 -- it became the single biggest residual hotspot +# in ``QwtText.textSize`` on PyQt6. +# 2. PyQt6 returns a fresh Python wrapper around the same QFont on most +# calls, so ``id(font)`` changes between calls and the id-keyed fast path +# misses ~92% of the time. The tuple-key second level recovers the hits +# those misses would have produced, without paying for ``font.key()``. +# +# The tuple key uses ``(family, pixelSize-or-pointSizeF, weight, italic, +# stretch, styleStrategy)``. This is what determines ``QFontMetrics`` output +# in practice; if two QFonts share these values they share metrics. + +_FONT_KEY_CACHE: dict = {} # id(font) -> tuple_key (fast path) +_FONT_TUPLE_CACHE: dict = {} # tuple_key -> tuple_key (interning, also acts +# as the "have we seen this logical font" set) +_FONT_KEY_CACHE_LIMIT = 1024 + + +def _font_tuple_key(font): + """Build a hashable tuple identifying the logical font.""" + px = font.pixelSize() + return ( + font.family(), + px if px > 0 else font.pointSizeF(), + font.weight(), + font.italic(), + font.stretch(), + font.styleStrategy(), + ) + + +def font_key_cached(font): + """Return a hashable cache key uniquely identifying ``font`` for metrics. + + The returned value is **not** ``QFont.key()`` -- it is a tuple computed + from a handful of QFont attributes. It is safe to use as a dict key for + metrics caches (callers in this module always compare by ``==`` only). + """ + fid = id(font) + entry = _FONT_KEY_CACHE.get(fid) + if entry is not None: + return entry[1] + tkey = _font_tuple_key(font) + # Intern: reuse the same tuple object across all id() variants so dict + # lookups in caller-side caches benefit from object-identity hash hits. + interned = _FONT_TUPLE_CACHE.setdefault(tkey, tkey) + if len(_FONT_KEY_CACHE) >= _FONT_KEY_CACHE_LIMIT: + _FONT_KEY_CACHE.clear() + _FONT_KEY_CACHE[fid] = (font, interned) + return interned + + +def get_screen_resolution(): + """Return screen resolution: tuple of floats (DPIx, DPIy)""" + try: + desktop = QApplication.desktop() + return (desktop.logicalDpiX(), desktop.logicalDpiY()) + except AttributeError: + screen = QApplication.primaryScreen() + return (screen.logicalDotsPerInchX(), screen.logicalDotsPerInchY()) + + +def qwtUnscaleFont(painter): + if painter.font().pixelSize() >= 0: + return + dpix, dpiy = get_screen_resolution() + pd = painter.device() + if pd.logicalDpiX() != dpix or pd.logicalDpiY() != dpiy: + try: + pixelFont = QFont(painter.font(), QApplication.desktop()) + except AttributeError: + pixelFont = QFont(painter.font()) + pixelFont.setPixelSize(QFontInfo(pixelFont).pixelSize()) + painter.setFont(pixelFont) + + +class QwtPlainTextEngine(QwtTextEngine): + """ + A text engine for plain texts + + `QwtPlainTextEngine` renders texts using the basic `Qt` classes + `QPainter` and `QFontMetrics`. + """ + + def __init__(self): + self.qrectf_max = QRectF(0, 0, QWIDGETSIZE_MAX, QWIDGETSIZE_MAX) + self._fm_cache = {} + self._fm_cache_f = {} + self._margins_cache = {} + # Fast path: when textMargins is called repeatedly with the same + # QFont instance, skip the (expensive) font.key() Qt call. + self._margins_last_id = -1 + self._margins_last_value = None + + def fontmetrics(self, font): + fid = font_key_cached(font) + try: + return self._fm_cache[fid] + except KeyError: + return self._fm_cache.setdefault(fid, QFontMetrics(font)) + + def fontmetrics_f(self, font): + fid = font_key_cached(font) + try: + return self._fm_cache_f[fid] + except KeyError: + return self._fm_cache_f.setdefault(fid, QFontMetricsF(font)) + + def heightForWidth(self, font, flags, text, width): + """ + Find the height for a given width + + :param QFont font: Font of the text + :param int flags: Bitwise OR of the flags used like in QPainter::drawText + :param str text: Text to be rendered + :param float width: Width + :return: Calculated height + """ + fm = self.fontmetrics_f(font) + rect = fm.boundingRect(QRectF(0, 0, width, QWIDGETSIZE_MAX), flags, text) + return rect.height() + + def textSize(self, font, flags, text): + """ + Returns the size, that is needed to render text + + :param QFont font: Font of the text + :param int flags: Bitwise OR of the flags like in for QPainter::drawText + :param str text: Text to be rendered + :return: Calculated size + """ + fm = self.fontmetrics_f(font) + rect = fm.boundingRect(self.qrectf_max, flags, text) + return rect.size() + + def effectiveAscent(self, font): + global ASCENTCACHE + fontKey = font_key_cached(font) + ascent = ASCENTCACHE.get(fontKey) + if ascent is not None: + return ascent + return ASCENTCACHE.setdefault(fontKey, self.findAscent(font)) + + def findAscent(self, font): + dummy = "E" + white = QColor(Qt.white) + + fm = self.fontmetrics(font) + boundingr = fm.boundingRect(dummy) + pm = QPixmap(boundingr.width(), boundingr.height()) + pm.fill(white) -from qwt.qt.QtGui import (QPainter, QFrame, QSizePolicy, QPalette, QFont, - QFontMetrics, QApplication, QColor) -from qwt.qt.QtCore import Qt, QSizeF, QSize, QRectF + p = QPainter(pm) + p.setFont(font) + p.drawText(0, 0, pm.width(), pm.height(), 0, dummy) + p.end() -import numpy as np + img = pm.toImage() + + w = pm.width() + linebytes = w * 4 + for row in range(img.height()): + if QT_API.startswith("pyside"): + line = bytes(img.scanLine(row)) + else: + line = img.scanLine(row).asstring(linebytes) + for col in range(w): + color = struct.unpack("I", line[col * 4 : (col + 1) * 4])[0] + if color != white.rgb(): + return fm.ascent() - row + 1 + return fm.ascent() + + def textMargins(self, font): + """ + Return margins around the texts + + The textSize might include margins around the + text, like QFontMetrics::descent(). In situations + where texts need to be aligned in detail, knowing + these margins might improve the layout calculations. + + :param QFont font: Font of the text + :return: tuple (left, right, top, bottom) representing margins + """ + # Fast path: same QFont object as the previous call. + font_id = id(font) + if font_id == self._margins_last_id: + return self._margins_last_value + fkey = font_key_cached(font) + cached = self._margins_cache.get(fkey) + if cached is None: + fm = self.fontmetrics(font) + cached = (0, 0, fm.ascent() - self.effectiveAscent(font), fm.descent()) + self._margins_cache[fkey] = cached + self._margins_last_id = font_id + self._margins_last_value = cached + return cached + + def draw(self, painter, rect, flags, text): + """ + Draw the text in a clipping rectangle + + :param QPainter painter: Painter + :param QRectF rect: Clipping rectangle + :param int flags: Bitwise OR of the flags like in for QPainter::drawText() + :param str text: Text to be rendered + """ + painter.save() + + # Get and configure font for better rendering of rotated text + font = painter.font() + # Disable hinting to avoid character misalignment in rotated text + font.setHintingPreference(QFont.PreferNoHinting) + painter.setFont(font) + + qwtUnscaleFont(painter) + painter.drawText(rect, flags, text) + painter.restore() + + def mightRender(self, text): + """ + Test if a string can be rendered by this text engine + + :param str text: Text to be tested + :return: True, if it can be rendered + """ + return True + + +class QwtRichTextEngine(QwtTextEngine): + """ + A text engine for `Qt` rich texts + + `QwtRichTextEngine` renders `Qt` rich texts using the classes + of the Scribe framework of `Qt`. + """ + + def __init__(self): + pass + + def heightForWidth(self, font, flags, text, width): + """ + Find the height for a given width + + :param QFont font: Font of the text + :param int flags: Bitwise OR of the flags used like in QPainter::drawText + :param str text: Text to be rendered + :param float width: Width + :return: Calculated height + """ + doc = QwtRichTextDocument(text, flags, font) + doc.setPageSize(QSizeF(width, QWIDGETSIZE_MAX)) + return doc.documentLayout().documentSize().height() + + def textSize(self, font, flags, text): + """ + Returns the size, that is needed to render text + + :param QFont font: Font of the text + :param int flags: Bitwise OR of the flags like in for QPainter::drawText + :param str text: Text to be rendered + :return: Calculated size + """ + doc = QwtRichTextDocument(text, flags, font) + option = doc.defaultTextOption() + if option.wrapMode() != QTextOption.NoWrap: + option.setWrapMode(QTextOption.NoWrap) + doc.setDefaultTextOption(option) + doc.adjustSize() + return doc.size() + + def draw(self, painter, rect, flags, text): + """ + Draw the text in a clipping rectangle + + :param QPainter painter: Painter + :param QRectF rect: Clipping rectangle + :param int flags: Bitwise OR of the flags like in for QPainter::drawText() + :param str text: Text to be rendered + """ + txt = QwtRichTextDocument(text, flags, painter.font()) + painter.save() + unscaledRect = QRectF(rect) + if painter.font().pixelSize() < 0: + dpix, dpiy = get_screen_resolution() + pd = painter.device() + if pd.logicalDpiX() != dpix or pd.logicalDpiY() != dpiy: + transform = QTransform() + transform.scale( + dpix / float(pd.logicalDpiX()), dpiy / float(pd.logicalDpiY()) + ) + painter.setWorldTransform(transform, True) + invtrans, _ok = transform.inverted() + unscaledRect = invtrans.mapRect(rect) + txt.setDefaultFont(painter.font()) + txt.setPageSize(QSizeF(unscaledRect.width(), QWIDGETSIZE_MAX)) + layout = txt.documentLayout() + height = layout.documentSize().height() + y = unscaledRect.y() + if flags & Qt.AlignBottom: + y += unscaledRect.height() - height + elif flags & Qt.AlignVCenter: + y += (unscaledRect.height() - height) / 2 + context = QAbstractTextDocumentLayout.PaintContext() + context.palette.setColor(QPalette.Text, painter.pen().color()) + painter.translate(unscaledRect.x(), y) + layout.draw(painter, context) + painter.restore() + + def taggedText(self, text, flags): + return taggedRichText(text, flags) + + def mightRender(self, text): + """ + Test if a string can be rendered by this text engine + + :param str text: Text to be tested + :return: True, if it can be rendered + """ + try: + return Qt.mightBeRichText(text) + except AttributeError: + return True + + def textMargins(self, font): + """ + Return margins around the texts + + The textSize might include margins around the + text, like QFontMetrics::descent(). In situations + where texts need to be aligned in detail, knowing + these margins might improve the layout calculations. + + :param QFont font: Font of the text + :return: tuple (left, right, top, bottom) representing margins + """ + return 0, 0, 0, 0 class QwtText_PrivateData(object): + # ``QObject`` was previously used as the base class but no Qt signals + # or events are ever emitted from ``_PrivateData`` containers and the + # ``QObject.__init__`` call dominates ``QwtText.__init__`` (it is the + # single most expensive line for tick-label-heavy renders, see + # https://github.com/PlotPyStack/PythonQwt/issues/93). + __slots__ = ( + "renderFlags", + "borderRadius", + "borderPen", + "backgroundBrush", + "paintAttributes", + "layoutAttributes", + "textEngine", + "text", + "font", + "color", + ) + def __init__(self): self.renderFlags = Qt.AlignCenter self.borderRadius = 0 @@ -24,211 +584,579 @@ def __init__(self): self.paintAttributes = 0 self.layoutAttributes = 0 self.textEngine = None - + self.text = None self.font = None self.color = None + class QwtText_LayoutCache(object): def __init__(self): - self.textSize = QSizeF() - self.font = None - + self.textSize = None + self.fontKey = None + self.fontId = -1 + def invalidate(self): - self.textSize = QSizeF() + self.textSize = None + self.fontKey = None + self.fontId = -1 + class QwtText(object): + """ + A class representing a text + + A `QwtText` is a text including a set of attributes how to render it. + + - Format: + + A text might include control sequences (f.e tags) describing + how to render it. Each format (f.e MathML, TeX, Qt Rich Text) + has its own set of control sequences, that can be handles by + a special `QwtTextEngine` for this format. + + - Background: + + A text might have a background, defined by a `QPen` and `QBrush` + to improve its visibility. The corners of the background might + be rounded. + + - Font: + + A text might have an individual font. + + - Color + + A text might have an individual color. + + - Render Flags + + Flags from `Qt.AlignmentFlag` and `Qt.TextFlag` used like in + `QPainter.drawText()`. + + ..seealso:: + + :py:meth:`qwt.text.QwtTextEngine`, + :py:meth:`qwt.text.QwtTextLabel` + + Text formats: + + * `QwtText.AutoText`: + + The text format is determined using `QwtTextEngine.mightRender()` for + all available text engines in increasing order > PlainText. + If none of the text engines can render the text is rendered + like `QwtText.PlainText`. + + * `QwtText.PlainText`: + + Draw the text as it is, using a QwtPlainTextEngine. + + * `QwtText.RichText`: + + Use the Scribe framework (Qt Rich Text) to render the text. + + * `QwtText.OtherFormat`: + + The number of text formats can be extended using `setTextEngine`. + Formats >= `QwtText.OtherFormat` are not used by Qwt. + + Paint attributes: + + * `QwtText.PaintUsingTextFont`: The text has an individual font. + * `QwtText.PaintUsingTextColor`: The text has an individual color. + * `QwtText.PaintBackground`: The text has an individual background. + + Layout attributes: + + * `QwtText.MinimumLayout`: + + Layout the text without its margins. This mode is useful if a + text needs to be aligned accurately, like the tick labels of a scale. + If `QwtTextEngine.textMargins` is not implemented for the format + of the text, `MinimumLayout` has no effect. + + .. py:class:: QwtText([text=None], [textFormat=None], [other=None]) + + :param str text: Text content + :param int textFormat: Text format + :param qwt.text.QwtText other: Object to copy (text and textFormat arguments are ignored) + """ # enum TextFormat - AutoText, PlainText, RichText, MathMLText, TeXText = list(range(5)) + AutoText, PlainText, RichText = list(range(3)) OtherFormat = 100 - + # enum PaintAttribute PaintUsingTextFont = 0x01 PaintUsingTextColor = 0x02 PaintBackground = 0x04 - + # enum LayoutAttribute MinimumLayout = 0x01 - def __init__(self, *args): - self._desktopwidget = None - self._dict = QwtTextEngineDict() - if len(args) in (0, 2): - if len(args) == 2: - text, textFormat = args - else: - text, textFormat = "", self.AutoText + # Optimization: a single text engine for all QwtText objects + # (this is not how it's implemented in Qwt6 C++ library) + __map = {PlainText: QwtPlainTextEngine(), RichText: QwtRichTextEngine()} + + def __init__(self, text=None, textFormat=None, other=None): + if text is None: + text = "" + if textFormat is None: + textFormat = self.AutoText + if other is not None: + text = other + if isinstance(text, QwtText): + self.__data = text.__data + self.__layoutCache = text.__layoutCache + else: self.__data = QwtText_PrivateData() self.__data.text = text self.__data.textEngine = self.textEngine(text, textFormat) self.__layoutCache = QwtText_LayoutCache() - elif len(args) == 1: - if isinstance(args[0], QwtText): - other, = args - self.__data = other.__data - self.__layoutCache = other.__layoutCache - else: - text, = args - textFormat = self.AutoText - self.__data = QwtText_PrivateData() - self.__data.text = text - self.__data.textEngine = self.textEngine(text, textFormat) - self.__layoutCache = QwtText_LayoutCache() - else: - raise TypeError("%s() takes 0, 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) - - @property - def desktopwidget(self): - if self._desktopwidget is None: - self._desktopwidget = QApplication.desktop() - return self._desktopwidget - + + @classmethod + def make( + cls, + text=None, + textformat=None, + renderflags=None, + font=None, + family=None, + pointsize=None, + weight=None, + color=None, + borderradius=None, + borderpen=None, + brush=None, + ): + """ + Create and setup a new `QwtText` object (convenience function). + + :param str text: Text content + :param int textformat: Text format + :param int renderflags: Flags from `Qt.AlignmentFlag` and `Qt.TextFlag` + :param font: Font + :type font: QFont or None + :param family: Font family (default: Helvetica) + :type family: str or None + :param pointsize: Font point size (default: 10) + :type pointsize: int or None + :param weight: Font weight (default: QFont.Normal) + :type weight: int or None + :param color: Pen color + :type color: QColor or str or None + :param borderradius: Radius for the corners of the border frame + :type borderradius: float or None + :param borderpen: Background pen + :type borderpen: QPen or None + :param brush: Background brush + :type brush: QBrush or None + + .. seealso:: + + :py:meth:`setText()` + """ + item = cls(text=text, textFormat=textformat) + if renderflags is not None: + item.setRenderFlags(renderflags) + if font is not None: + item.setFont(font) + elif family is not None or pointsize is not None or weight is not None: + family = "Helvetica" if family is None else family + pointsize = 10 if pointsize is None else pointsize + weight = QFont.Normal if weight is None else weight + item.setFont(QFont(family, pointsize, weight)) + if color is not None: + item.setColor(qcolor_from_str(color, Qt.black)) + if borderradius is not None: + item.setBorderRadius(borderradius) + if borderpen is not None: + item.setBorderPen(borderpen) + if brush is not None: + item.setBackgroundBrush(brush) + return item + def __eq__(self, other): - return self.__data.renderFlags == other.__data.renderFlags and\ - self.__data.text == other.__data.text and\ - self.__data.font == other.__data.font and\ - self.__data.color == other.__data.color and\ - self.__data.borderRadius == other.__data.borderRadius and\ - self.__data.borderPen == other.__data.borderPen and\ - self.__data.backgroundBrush == other.__data.backgroundBrush and\ - self.__data.paintAttributes == other.__data.paintAttributes and\ - self.__data.textEngine == other.__data.textEngine + return ( + self.__data.renderFlags == other.__data.renderFlags + and self.__data.text == other.__data.text + and self.__data.font == other.__data.font + and self.__data.color == other.__data.color + and self.__data.borderRadius == other.__data.borderRadius + and self.__data.borderPen == other.__data.borderPen + and self.__data.backgroundBrush == other.__data.backgroundBrush + and self.__data.paintAttributes == other.__data.paintAttributes + and self.__data.textEngine == other.__data.textEngine + ) def __ne__(self, other): return not self.__eq__(other) def isEmpty(self): + """ + :return: True if text is empty + """ return len(self.text()) == 0 def setText(self, text, textFormat=None): + """ + Assign a new text content + + :param str text: Text content + :param int textFormat: Text format + + .. seealso:: + + :py:meth:`text()` + """ if textFormat is None: textFormat = self.AutoText self.__data.text = text self.__data.textEngine = self.textEngine(text, textFormat) self.__layoutCache.invalidate() - + def text(self): + """ + :return: Text content + + .. seealso:: + + :py:meth:`setText()` + """ return self.__data.text - + def setRenderFlags(self, renderFlags): - renderFlags = Qt.AlignmentFlag(renderFlags) + """ + Change the render flags + + The default setting is `Qt.AlignCenter` + + :param int renderFlags: Bitwise OR of the flags used like in `QPainter.drawText()` + + .. seealso:: + + :py:meth:`renderFlags()`, + :py:meth:`qwt.text.QwtTextEngine.draw()` + """ + # Wrap into Qt.AlignmentFlag so that downstream Qt APIs (notably + # ``QTextOption.setAlignment``, ``QPainter.drawText``, + # ``QFontMetrics.boundingRect``) that strictly require an enum on + # PyQt6 keep working. Hot bitwise-test sites locally cast back to + # int to avoid the per-test enum.__and__ cost. + if not isinstance(renderFlags, Qt.AlignmentFlag): + renderFlags = Qt.AlignmentFlag(renderFlags) if renderFlags != self.__data.renderFlags: self.__data.renderFlags = renderFlags self.__layoutCache.invalidate() - + def renderFlags(self): + """ + :return: Render flags + + .. seealso:: + + :py:meth:`setRenderFlags()` + """ return self.__data.renderFlags - + def setFont(self, font): + """ + Set the font. + + :param QFont font: Font + + .. note:: + + Setting the font might have no effect, when + the text contains control sequences for setting fonts. + + .. seealso:: + + :py:meth:`font()`, :py:meth:`usedFont()` + """ self.__data.font = font self.setPaintAttribute(self.PaintUsingTextFont) - + def font(self): + """ + :return: Return the font + + .. seealso:: + + :py:meth:`setFont()`, :py:meth:`usedFont()` + """ return self.__data.font - + def usedFont(self, defaultFont): + """ + Return the font of the text, if it has one. + Otherwise return defaultFont. + + :param QFont defaultFont: Default font + :return: Font used for drawing the text + + .. seealso:: + + :py:meth:`setFont()`, :py:meth:`font()` + """ if self.__data.paintAttributes & self.PaintUsingTextFont: return self.__data.font return defaultFont - + def setColor(self, color): + """ + Set the pen color used for drawing the text. + + :param QColor color: Color + + .. note:: + + Setting the color might have no effect, when + the text contains control sequences for setting colors. + + .. seealso:: + + :py:meth:`color()`, :py:meth:`usedColor()` + """ self.__data.color = QColor(color) self.setPaintAttribute(self.PaintUsingTextColor) - + def color(self): + """ + :return: Return the pen color, used for painting the text + + .. seealso:: + + :py:meth:`setColor()`, :py:meth:`usedColor()` + """ return self.__data.color - + def usedColor(self, defaultColor): + """ + Return the color of the text, if it has one. + Otherwise return defaultColor. + + :param QColor defaultColor: Default color + :return: Color used for drawing the text + + .. seealso:: + + :py:meth:`setColor()`, :py:meth:`color()` + """ if self.__data.paintAttributes & self.PaintUsingTextColor: return self.__data.color return defaultColor - + def setBorderRadius(self, radius): - self.__data.borderRadius = max([0., radius]) - + """ + Set the radius for the corners of the border frame + + :param float radius: Radius of a rounded corner + + .. seealso:: + + :py:meth:`borderRadius()`, :py:meth:`setBorderPen()`, + :py:meth:`setBackgroundBrush()` + """ + self.__data.borderRadius = max([0.0, radius]) + def borderRadius(self): + """ + :return: Radius for the corners of the border frame + + .. seealso:: + + :py:meth:`setBorderRadius()`, :py:meth:`borderPen()`, + :py:meth:`backgroundBrush()` + """ return self.__data.borderRadius - + def setBorderPen(self, pen): + """ + Set the background pen + + :param QPen pen: Background pen + + .. seealso:: + + :py:meth:`borderPen()`, :py:meth:`setBackgroundBrush()` + """ self.__data.borderPen = pen self.setPaintAttribute(self.PaintBackground) - + def borderPen(self): + """ + :return: Background pen + + .. seealso:: + + :py:meth:`setBorderPen()`, :py:meth:`backgroundBrush()` + """ return self.__data.borderPen - + def setBackgroundBrush(self, brush): + """ + Set the background brush + + :param QBrush brush: Background brush + + .. seealso:: + + :py:meth:`backgroundBrush()`, :py:meth:`setBorderPen()` + """ self.__data.backgroundBrush = brush self.setPaintAttribute(self.PaintBackground) - + def backgroundBrush(self): + """ + :return: Background brush + + .. seealso:: + + :py:meth:`setBackgroundBrush()`, :py:meth:`borderPen()` + """ return self.__data.backgroundBrush - + def setPaintAttribute(self, attribute, on=True): + """ + Change a paint attribute + + :param int attribute: Paint attribute + :param bool on: On/Off + + .. note:: + + Used by `setFont()`, `setColor()`, `setBorderPen()` + and `setBackgroundBrush()` + + .. seealso:: + + :py:meth:`testPaintAttribute()` + """ if on: self.__data.paintAttributes |= attribute else: self.__data.paintAttributes &= ~attribute - + def testPaintAttribute(self, attribute): + """ + Test a paint attribute + + :param int attribute: Paint attribute + :return: True, if attribute is enabled + + .. seealso:: + + :py:meth:`setPaintAttribute()` + """ return self.__data.paintAttributes & attribute - - + def setLayoutAttribute(self, attribute, on=True): + """ + Change a layout attribute + + :param int attribute: Layout attribute + :param bool on: On/Off + + .. seealso:: + + :py:meth:`testLayoutAttribute()` + """ if on: self.__data.layoutAttributes |= attribute else: self.__data.layoutAttributes &= ~attribute - + def testLayoutAttribute(self, attribute): + """ + Test a layout attribute + + :param int attribute: Layout attribute + :return: True, if attribute is enabled + + .. seealso:: + + :py:meth:`setLayoutAttribute()` + """ return self.__data.layoutAttributes & attribute - + def heightForWidth(self, width, defaultFont=None): + """ + Find the height for a given width + + :param float width: Width + :param QFont defaultFont: Font, used for the calculation if the text has no font + :return: Calculated height + """ if defaultFont is None: defaultFont = QFont() - font = QFont(self.usedFont(defaultFont), self.desktopwidget) + font = QFont(self.usedFont(defaultFont)) h = 0 if self.__data.layoutAttributes & self.MinimumLayout: - (left, right, top, bottom - ) = self.__data.textEngine.textMargins(font) - h = self.__data.textEngine.heightForWidth(font, - self.__data.renderFlags, self.__data.text, - width + left + right) + (left, right, top, bottom) = self.__data.textEngine.textMargins(font) + h = self.__data.textEngine.heightForWidth( + font, self.__data.renderFlags, self.__data.text, width + left + right + ) h -= top + bottom else: - h = self.__data.textEngine.heightForWidth(font, - self.__data.renderFlags, self.__data.text, width) + h = self.__data.textEngine.heightForWidth( + font, self.__data.renderFlags, self.__data.text, width + ) return h - + def textSize(self, defaultFont): - font = QFont(self.usedFont(defaultFont), self.desktopwidget) - if not self.__layoutCache.textSize.isValid() or\ - self.__layoutCache.font is not font: - self.__layoutCache.textSize =\ - self.__data.textEngine.textSize(font, self.__data.renderFlags, - self.__data.text) - self.__layoutCache.font = font - sz = self.__layoutCache.textSize + """ + Returns the size, that is needed to render text + + :param QFont defaultFont Font, used for the calculation if the text has no font + :return: Caluclated size + """ + font = self.usedFont(defaultFont) + cache = self.__layoutCache + font_id = id(font) + if cache.textSize is not None and cache.fontId == font_id: + sz = QSizeF(cache.textSize) + else: + fkey = font_key_cached(font) + if ( + cache.textSize is None + or not cache.textSize.isValid() + or cache.fontKey != fkey + ): + cache.textSize = self.__data.textEngine.textSize( + font, self.__data.renderFlags, self.__data.text + ) + cache.fontKey = fkey + cache.fontId = font_id + sz = QSizeF(cache.textSize) if self.__data.layoutAttributes & self.MinimumLayout: - (left, right, top, bottom - ) = self.__data.textEngine.textMargins(font) + (left, right, top, bottom) = self.__data.textEngine.textMargins(font) sz -= QSizeF(left + right, top + bottom) return sz - + def draw(self, painter, rect): + """ + Draw a text into a rectangle + + :param QPainter painter: Painter + :param QRectF rect: Rectangle + """ if self.__data.paintAttributes & self.PaintBackground: - if self.__data.borderPen != Qt.NoPen or\ - self.__data.backgroundBrush != Qt.NoBrush: + if ( + self.__data.borderPen != Qt.NoPen + or self.__data.backgroundBrush != Qt.NoBrush + ): painter.save() painter.setPen(self.__data.borderPen) painter.setBrush(self.__data.backgroundBrush) if self.__data.borderRadius == 0: - QwtPainter.drawRect(painter, rect) + painter.drawRect(rect) else: painter.setRenderHint(QPainter.Antialiasing, True) - painter.drawRoundedRect(rect, self.__data.borderRadius, - self.__data.borderRadius) + painter.drawRoundedRect( + rect, self.__data.borderRadius, self.__data.borderRadius + ) painter.restore() painter.save() if self.__data.paintAttributes & self.PaintUsingTextFont: @@ -238,54 +1166,77 @@ def draw(self, painter, rect): painter.setPen(self.__data.color) expandedRect = rect if self.__data.layoutAttributes & self.MinimumLayout: - font = QFont(painter.font(), self.desktopwidget) - (left, right, top, bottom - ) = self.__data.textEngine.textMargins(font) - expandedRect.setTop(rect.top()-top) - expandedRect.setBottom(rect.bottom()+bottom) - expandedRect.setLeft(rect.left()-left) - expandedRect.setRight(rect.right()+right) - self.__data.textEngine.draw(painter, expandedRect, - self.__data.renderFlags, self.__data.text) + font = QFont(painter.font()) + (left, right, top, bottom) = self.__data.textEngine.textMargins(font) + expandedRect.setTop(rect.top() - top) + expandedRect.setBottom(rect.bottom() + bottom) + expandedRect.setLeft(rect.left() - left) + expandedRect.setRight(rect.right() + right) + self.__data.textEngine.draw( + painter, expandedRect, self.__data.renderFlags, self.__data.text + ) painter.restore() - - def textEngine(self, *args): - return self._dict.textEngine(*args) - - def setTextEngine(self, format_, engine): - self._dict.setTextEngine(format_, engine) + def textEngine(self, text=None, format_=None): + """ + Find the text engine for a text format -class QwtTextEngineDict(object): - # Optimization: a single text engine for all QwtText objects - # (this is not how it's implemented in Qwt6 C++ library) - __map = {QwtText.PlainText: QwtPlainTextEngine(), - QwtText.RichText: QwtRichTextEngine()} - - def textEngine(self, *args): - if len(args) == 1: - format_ = args[0] + In case of `QwtText.AutoText` the first text engine + (beside `QwtPlainTextEngine`) is returned, where + `QwtTextEngine.mightRender` returns true. + If there is none `QwtPlainTextEngine` is returned. + + If no text engine is registered for the format `QwtPlainTextEngine` + is returned. + + :param str text: Text, needed in case of AutoText + :param int format: Text format + :return: Corresponding text engine + """ + if text is None: return self.__map.get(format_) - elif len(args) == 2: - text, format_ = args - + elif format_ is not None: if format_ == QwtText.AutoText: - for key, engine in list(self.__map.items()): + # Fast path: a string with no ``<`` cannot be rich text, so + # we can return the plain engine without iterating the map + # and calling Qt.mightBeRichText (which is a hot Qt call + # for tick labels like " 1.5"). + if "<" not in text: + return self.__map[QwtText.PlainText] + for key, engine in self.__map.items(): if key != QwtText.PlainText: if engine and engine.mightRender(text): return engine - engine = self.__map.get(format_) if engine is not None: return engine - - engine = self.__map[QwtText.PlainText] - return engine + return self.__map[QwtText.PlainText] else: - raise TypeError("%s().textEngine() takes 1 or 2 argument(s) (%s "\ - "given)" % (self.__class__.__name__, len(args))) - + raise TypeError( + "%s().textEngine() takes 1 or 2 argument(s) (none" + " given)" % self.__class__.__name__ + ) + def setTextEngine(self, format_, engine): + """ + Assign/Replace a text engine for a text format + + With setTextEngine it is possible to extend `PythonQwt` with + other types of text formats. + + For `QwtText.PlainText` it is not allowed to assign a engine to None. + + :param int format_: Text format + :param qwt.text.QwtTextEngine engine: Text engine + + .. seealso:: + + :py:meth:`setPaintAttribute()` + + .. warning:: + + Using `QwtText.AutoText` does nothing. + """ if format_ == QwtText.AutoText: return if format_ == QwtText.PlainText and engine is None: @@ -293,107 +1244,210 @@ def setTextEngine(self, format_, engine): self.__map.setdefault(format_, engine) -class QwtTextLabel_PrivateData(object): +class QwtTextLabel_PrivateData(QObject): def __init__(self): + QObject.__init__(self) + self.indent = 4 self.margin = 0 self.text = QwtText() class QwtTextLabel(QFrame): + """ + A Widget which displays a QwtText + + .. py:class:: QwtTextLabel(parent) + + :param QWidget parent: Parent widget + + .. py:class:: QwtTextLabel([text=None], [parent=None]) + :noindex: + + :param str text: Text + :param QWidget parent: Parent widget + """ + def __init__(self, *args): if len(args) == 0: text, parent = None, None elif len(args) == 1: - text = None - parent, = args + if isinstance(args[0], QWidget): + text = None + (parent,) = args + else: + parent = None + (text,) = args elif len(args) == 2: text, parent = args else: - raise TypeError("%s() takes 0, 1 or 2 argument(s) (%s given)"\ - % (self.__class__.__name__, len(args))) + raise TypeError( + "%s() takes 0, 1 or 2 argument(s) (%s given)" + % (self.__class__.__name__, len(args)) + ) super(QwtTextLabel, self).__init__(parent) self.init() if text is not None: self.__data.text = text - + def init(self): self.__data = QwtTextLabel_PrivateData() self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - + def setPlainText(self, text): + """ + Interface for the designer plugin - does the same as setText() + + :param str text: Text + + .. seealso:: + + :py:meth:`plainText()` + """ self.setText(QwtText(text)) - + def plainText(self): + """ + Interface for the designer plugin + + :return: Text as plain text + + .. seealso:: + + :py:meth:`setPlainText()` + """ return self.__data.text.text() - + def setText(self, text, textFormat=QwtText.AutoText): + """ + Change the label's text, keeping all other QwtText attributes + + :param text: New text + :type text: qwt.text.QwtText or str + :param int textFormat: Format of text + + .. seealso:: + + :py:meth:`text()` + """ if isinstance(text, QwtText): self.__data.text = text else: self.__data.text.setText(text, textFormat) self.update() self.updateGeometry() - + def text(self): + """ + :return: Return the text + + .. seealso:: + + :py:meth:`setText()` + """ return self.__data.text - + def clear(self): + """ + Clear the text and all `QwtText` attributes + """ self.__data.text = QwtText() self.update() self.updateGeometry() - + def indent(self): + """ + :return: Label's text indent in pixels + + .. seealso:: + + :py:meth:`setIndent()` + """ return self.__data.indent - + def setIndent(self, indent): + """ + Set label's text indent in pixels + + :param int indent: Indentation in pixels + + .. seealso:: + + :py:meth:`indent()` + """ if indent < 0: indent = 0 self.__data.indent = indent self.update() self.updateGeometry() - + def margin(self): + """ + :return: Label's text indent in pixels + + .. seealso:: + + :py:meth:`setMargin()` + """ return self.__data.margin - + def setMargin(self, margin): + """ + Set label's margin in pixels + + :param int margin: Margin in pixels + + .. seealso:: + + :py:meth:`margin()` + """ self.__data.margin = margin self.update() self.updateGeometry() - + def sizeHint(self): + """ + Return a size hint + """ return self.minimumSizeHint() - + def minimumSizeHint(self): + """ + Return a minimum size hint + """ sz = self.__data.text.textSize(self.font()) - mw = 2*(self.frameWidth()+self.__data.margin) + mw = 2 * (self.frameWidth() + self.__data.margin) mh = mw indent = self.__data.indent if indent <= 0: indent = self.defaultIndent() if indent > 0: - align = self.__data.text.renderFlags() - if align & Qt.AlignLeft or align & Qt.AlignRight: + align = _flag_int(self.__data.text.renderFlags()) + if align & (_ALIGN_LEFT | _ALIGN_RIGHT): mw += self.__data.indent - elif align & Qt.AlignTop or align & Qt.AlignBottom: + elif align & (_ALIGN_TOP | _ALIGN_BOTTOM): mh += self.__data.indent sz += QSizeF(mw, mh) - return QSize(np.ceil(sz.width()), np.ceil(sz.height())) + return QSize(math.ceil(sz.width()), math.ceil(sz.height())) def heightForWidth(self, width): - renderFlags = self.__data.text.renderFlags() + """ + :param int width: Width + :return: Preferred height for this widget, given the width. + """ + renderFlags = _flag_int(self.__data.text.renderFlags()) indent = self.__data.indent if indent <= 0: indent = self.defaultIndent() - width -= 2*self.frameWidth() - if renderFlags & Qt.AlignLeft or renderFlags & Qt.AlignRight: + width -= 2 * self.frameWidth() + if renderFlags & (_ALIGN_LEFT | _ALIGN_RIGHT): width -= indent - height = np.ceil(self.__data.text.heightForWidth(width, self.font())) - if renderFlags & Qt.AlignTop or renderFlags & Qt.AlignBottom: + height = math.ceil(self.__data.text.heightForWidth(width, self.font())) + if renderFlags & (_ALIGN_TOP | _ALIGN_BOTTOM): height += indent - height += 2*self.frameWidth() + height += 2 * self.frameWidth() return height - + def paintEvent(self, event): painter = QPainter(self) if not self.contentsRect().contains(event.rect()): @@ -403,8 +1457,13 @@ def paintEvent(self, event): painter.restore() painter.setClipRegion(event.region() & self.contentsRect()) self.drawContents(painter) - + def drawContents(self, painter): + """ + Redraw the text and focus indicator + + :param QPainter painter: Painter + """ r = self.textRect() if r.isEmpty(): return @@ -413,34 +1472,48 @@ def drawContents(self, painter): self.drawText(painter, QRectF(r)) if self.hasFocus(): m = 2 - focusRect = self.contentsRect().adjusted(m, m, -m+1, -m+1) + focusRect = self.contentsRect().adjusted(m, m, -m + 1, -m + 1) QwtPainter.drawFocusRect(painter, self, focusRect) - + def drawText(self, painter, textRect): + """ + Redraw the text + + :param QPainter painter: Painter + :param QRectF textRect: Text rectangle + """ self.__data.text.draw(painter, textRect) - + def textRect(self): + """ + Calculate geometry for the text in widget coordinates + + :return: Geometry for the text + """ r = self.contentsRect() if not r.isEmpty() and self.__data.margin > 0: - r.setRect(r.x()+self.__data.margin, r.y()+self.__data.margin, - r.width()-2*self.__data.margin, - r.height()-2*self.__data.margin) + r.setRect( + r.x() + self.__data.margin, + r.y() + self.__data.margin, + r.width() - 2 * self.__data.margin, + r.height() - 2 * self.__data.margin, + ) if not r.isEmpty(): indent = self.__data.indent if indent <= 0: indent = self.defaultIndent() if indent > 0: - renderFlags = self.__data.text.renderFlags() - if renderFlags & Qt.AlignLeft: - r.setX(r.x()+indent) - elif renderFlags & Qt.AlignRight: - r.setWidth(r.width()-indent) - elif renderFlags & Qt.AlignTop: - r.setY(r.y()+indent) - elif renderFlags & Qt.AlignBottom: - r.setHeight(r.height()-indent) + renderFlags = _flag_int(self.__data.text.renderFlags()) + if renderFlags & _ALIGN_LEFT: + r.setX(r.x() + indent) + elif renderFlags & _ALIGN_RIGHT: + r.setWidth(r.width() - indent) + elif renderFlags & _ALIGN_TOP: + r.setY(r.y() + indent) + elif renderFlags & _ALIGN_BOTTOM: + r.setHeight(r.height() - indent) return r - + def defaultIndent(self): if self.frameWidth() <= 0: return 0 @@ -448,4 +1521,4 @@ def defaultIndent(self): fnt = self.__data.text.font() else: fnt = self.font() - return QFontMetrics(fnt).width('x')/2 + return QFontMetrics(fnt).boundingRect("x").width() / 2 diff --git a/qwt/text_engine.py b/qwt/text_engine.py deleted file mode 100644 index 5b2bcd0..0000000 --- a/qwt/text_engine.py +++ /dev/null @@ -1,174 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the Qwt License -# Copyright (c) 2002 Uwe Rathmann, for the original C++ code -# Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization -# (see LICENSE file for more details) - -from qwt.painter import QwtPainter - -from qwt.qt.QtGui import (QTextDocument, QTextOption, QColor, QFontMetricsF, - QPixmap, QPainter, QFontMetrics) -from qwt.qt.QtCore import Qt, QRectF, QSizeF - -import struct - -QWIDGETSIZE_MAX = (1<<24)-1 - - -def taggedRichText(text, flags): - richText = text - if flags & Qt.AlignJustify: - richText = "
" + richText + "
" - elif flags & Qt.AlignRight: - richText = "
" + richText + "
" - elif flags & Qt.AlignHCenter: - richText = "
" + richText + "
" - return richText - - -class QwtRichTextDocument(QTextDocument): - def __init__(self, text, flags, font): - super(QwtRichTextDocument, self).__init__(None) - self.setUndoRedoEnabled(False) - self.setDefaultFont(font) - self.setHtml(text) - - option = self.defaultTextOption() - if flags & Qt.TextWordWrap: - option.setWrapMode(QTextOption.WordWrap) - else: - option.setWrapMode(QTextOption.NoWrap) - - option.setAlignment(flags) - self.setDefaultTextOption(option) - - root = self.rootFrame() - fm = root.frameFormat() - fm.setBorder(0) - fm.setMargin(0) - fm.setPadding(0) - fm.setBottomMargin(0) - fm.setLeftMargin(0) - root.setFrameFormat(fm) - - self.adjustSize(); - - -class QwtTextEngine(object): - def __init__(self): - pass - - -ASCENTCACHE = {} - -class QwtPlainTextEngine(QwtTextEngine): - def __init__(self): - self.qrectf_max = QRectF(0, 0, QWIDGETSIZE_MAX, QWIDGETSIZE_MAX) - self._fm_cache = {} - self._fm_cache_f = {} - - def fontmetrics(self, font): - fid = font.toString() - fm = self._fm_cache.get(fid) - if fm is None: - return self._fm_cache.setdefault(fid, QFontMetrics(font)) - else: - return fm - - def fontmetrics_f(self, font): - fid = font.toString() - fm = self._fm_cache_f.get(fid) - if fm is None: - return self._fm_cache_f.setdefault(fid, QFontMetricsF(font)) - else: - return fm - - def heightForWidth(self, font, flags, text, width): - fm = self.fontmetrics_f(font) - rect = fm.boundingRect(QRectF(0, 0, width, QWIDGETSIZE_MAX), - flags, text) - return rect.height() - - def textSize(self, font, flags, text): - fm = self.fontmetrics_f(font) - rect = fm.boundingRect(self.qrectf_max, flags, text) - return rect.size() - - def effectiveAscent(self, font): - global ASCENTCACHE - fontKey = font.key() - ascent = ASCENTCACHE.get(fontKey) - if ascent is not None: - return ascent - return ASCENTCACHE.setdefault(fontKey, self.findAscent(font)) - - def findAscent(self, font): - dummy = "E" - white = QColor(Qt.white) - - fm = self.fontmetrics(font) - pm = QPixmap(fm.width(dummy), fm.height()) - pm.fill(white) - - p = QPainter(pm) - p.setFont(font) - p.drawText(0, 0, pm.width(), pm.height(), 0, dummy) - p.end() - - img = pm.toImage() - - w = pm.width() - linebytes = w*4 - for row in range(img.height()): - line = img.scanLine(row).asstring(linebytes) - for col in range(w): - color = struct.unpack('I', line[col*4:(col+1)*4])[0] - if color != white.rgb(): - return fm.ascent()-row+1 - return fm.ascent() - - def textMargins(self, font): - left = right = top = 0 - fm = self.fontmetrics_f(font) - top = fm.ascent() - self.effectiveAscent(font) - bottom = fm.descent() - return left, right, top, bottom - - def draw(self, painter, rect, flags, text): - QwtPainter.drawText(painter, rect, flags, text) - - def mightRender(self, text): - return True - - -class QwtRichTextEngine(QwtTextEngine): - def __init__(self): - pass - - def heightForWidth(self, font, flags, text, width): - doc = QwtRichTextDocument(text, flags, font) - doc.setPageSize(QSizeF(width, QWIDGETSIZE_MAX)) - return doc.documentLayout().documentSize().height() - - def textSize(self, font, flags, text): - doc = QwtRichTextDocument(text, flags, font) - option = doc.defaultTextOption() - if option.wrapMode() != QTextOption.NoWrap: - option.setWrapMode(QTextOption.NoWrap) - doc.setDefaultTextOption(option) - doc.adjustSize() - return doc.size() - - def draw(self, painter, rect, flags, text): - doc = QwtRichTextDocument(text, flags, painter.font()) - QwtPainter.drawSimpleRichText(painter, rect, flags, doc) - - def taggedText(self, text, flags): - return self.taggedRichText(text,flags) - - def mightRender(self, text): - return Qt.mightBeRichText(text) - - def textMargins(self, font): - return 0, 0, 0, 0 diff --git a/qwt/toqimage.py b/qwt/toqimage.py index d52226b..b788901 100644 --- a/qwt/toqimage.py +++ b/qwt/toqimage.py @@ -3,13 +3,25 @@ # Licensed under the terms of the MIT License # (see LICENSE file for more details) -from qwt.qt.QtGui import QImage +""" +NumPy array to QImage +--------------------- + +.. autofunction:: array_to_qimage +""" import numpy as np +from qtpy.QtGui import QImage def array_to_qimage(arr, copy=False): - """Convert NumPy array to QImage object""" + """ + Convert NumPy array to QImage object + + :param numpy.array arr: NumPy array + :param bool copy: if True, make a copy of the array + :return: QImage object + """ # https://gist.githubusercontent.com/smex/5287589/raw/toQImage.py if arr is None: return QImage() @@ -24,7 +36,7 @@ def array_to_qimage(arr, copy=False): if arr.dtype == np.uint8: if color_dim is None: qimage = QImage(data, nx, ny, stride, QImage.Format_Indexed8) -# qimage.setColorTable([qRgb(i, i, i) for i in range(256)]) + # qimage.setColorTable([qRgb(i, i, i) for i in range(256)]) qimage.setColorCount(256) elif color_dim == 3: qimage = QImage(data, nx, ny, stride, QImage.Format_RGB888) diff --git a/qwt/transform.py b/qwt/transform.py index 304d6be..00b72a7 100644 --- a/qwt/transform.py +++ b/qwt/transform.py @@ -5,69 +5,254 @@ # Copyright (c) 2015 Pierre Raybaut, for the Python translation/optimization # (see LICENSE file for more details) +""" +Coordinate tranformations +------------------------- + +QwtTransform +~~~~~~~~~~~~ + +.. autoclass:: QwtTransform + :members: + +QwtNullTransform +~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtNullTransform + :members: + +QwtLogTransform +~~~~~~~~~~~~~~~ + +.. autoclass:: QwtLogTransform + :members: + +QwtPowerTransform +~~~~~~~~~~~~~~~~~ + +.. autoclass:: QwtPowerTransform + :members: +""" + import numpy as np class QwtTransform(object): + """ + A transformation between coordinate systems + + QwtTransform manipulates values, when being mapped between + the scale and the paint device coordinate system. + + A transformation consists of 2 methods: + + - transform + - invTransform + + where one is is the inverse function of the other. + + When p1, p2 are the boundaries of the paint device coordinates + and s1, s2 the boundaries of the scale, QwtScaleMap uses the + following calculations:: + + p = p1 + (p2 - p1) * ( T(s) - T(s1) / (T(s2) - T(s1)) ) + s = invT( T(s1) + ( T(s2) - T(s1) ) * (p - p1) / (p2 - p1) ) + """ + def __init__(self): pass - + def bounded(self, value): + """ + Modify value to be a valid value for the transformation. + The default implementation does nothing. + """ return value - + def transform(self, value): + """ + Transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`invTransform()` + """ raise NotImplementedError - + def invTransform(self, value): + """ + Inverse transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`transform()` + """ raise NotImplementedError - + def copy(self): + """ + :return: Clone of the transformation + + The default implementation does nothing. + """ raise NotImplementedError class QwtNullTransform(QwtTransform): def transform(self, value): + """ + Transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`invTransform()` + """ return value - + def invTransform(self, value): + """ + Inverse transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`transform()` + """ return value - + def copy(self): + """ + :return: Clone of the transformation + """ return QwtNullTransform() class QwtLogTransform(QwtTransform): + """ + Logarithmic transformation + + `QwtLogTransform` modifies the values using `numpy.log()` and + `numpy.exp()`. + + .. note:: + + In the calculations of `QwtScaleMap` the base of the log function + has no effect on the mapping. So `QwtLogTransform` can be used + for logarithmic scale in base 2 or base 10 or any other base. + + Extremum values: + + * `QwtLogTransform.LogMin`: Smallest allowed value for logarithmic + scales: 1.0e-150 + * `QwtLogTransform.LogMax`: Largest allowed value for logarithmic + scales: 1.0e150 + """ + LogMin = 1.0e-150 LogMax = 1.0e150 + def bounded(self, value): - return np.clip(value, self.LogMin, self.LogMax) + """ + Modify value to be a valid value for the transformation. + + :param float value: Value to be bounded + :return: Value modified + """ + bval = np.clip(np.asarray(value, dtype=np.float64), self.LogMin, self.LogMax) + return bval.item() if bval.ndim == 0 else bval def transform(self, value): - return np.log(value) - + """ + Transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`invTransform()` + """ + return np.log(self.bounded(value)) + def invTransform(self, value): + """ + Inverse transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`transform()` + """ return np.exp(value) - + def copy(self): + """ + :return: Clone of the transformation + """ return QwtLogTransform() class QwtPowerTransform(QwtTransform): + """ + A transformation using `numpy.pow()` + + `QwtPowerTransform` preserves the sign of a value. + F.e. a transformation with a factor of 2 + transforms a value of -3 to -9 and v.v. Thus `QwtPowerTransform` + can be used for scales including negative values. + """ + def __init__(self, exponent): self.__exponent = exponent super(QwtPowerTransform, self).__init__() def transform(self, value): - if value < 0.: - return -np.pow(-value, 1./self.__exponent) + """ + Transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`invTransform()` + """ + if value < 0.0: + return -np.pow(-value, 1.0 / self.__exponent) else: - return np.pow(value, 1./self.__exponent) - + return np.pow(value, 1.0 / self.__exponent) + def invTransform(self, value): - if value < 0.: + """ + Inverse transformation function + + :param float value: Value + :return: Modified value + + .. seealso:: + + :py:meth:`transform()` + """ + if value < 0.0: return -np.pow(-value, self.__exponent) else: return np.pow(value, self.__exponent) - + def copy(self): - return QwtPowerTransform() + """ + :return: Clone of the transformation + """ + return QwtPowerTransform(self.__exponent) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..18abc82 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +numpy +QtPy +PyQt5 +pre-commit +pylint +pytest +pytest-xvfb +coverage +build +ruff +sphinx +python-docs-theme diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6594835 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,136 @@ +# PythonQwt performance & visual-regression scripts + +This folder collects the tooling that supports performance investigations and visual-regression checks on PythonQwt. It was assembled while working on [issue #93](https://github.com/PlotPyStack/PythonQwt/issues/93) ("Performance degradation with Qt6") and is meant to be reused whenever someone needs to either: + +- **Optimize a hot path** — without flying blind on which binding pays the cost. +- **Validate that a refactor did not regress performance** — for any of the supported Qt bindings. +- **Validate that a refactor did not regress rendered output** — pixel-comparison across the 22 visual tests. + +The full case study that produced these scripts is documented in [`doc/issue93_optimization_summary.md`](../doc/issue93_optimization_summary.md). Read it first if you want to see the scripts in action and understand the kinds of findings they enable. + +## Prerequisites: per-binding virtualenvs + +PythonQwt supports PyQt5, PyQt6 and PySide6, and the *whole point* of this tooling is to compare them. The scripts assume three sibling virtual environments under `.venvs/`: + +``` +PythonQwt/ +├── .venvs/ +│ ├── pyqt5/ # contains PyQt5 +│ ├── pyqt6/ # contains PyQt6 +│ └── pyside6/ # contains PySide6 +``` + +`.venvs/` is git-ignored (see [`.gitignore`](../.gitignore)). One-shot bootstrap (PowerShell): + +```powershell +foreach ($b in "pyqt5","pyqt6","pyside6") { + & py -3.11 -m venv ".venvs/$b" + & ".\.venvs\$b\Scripts\python.exe" -m pip install --upgrade pip + # Core: Qt binding + qtpy + numpy + test runner + tools used by the scripts + & ".\.venvs\$b\Scripts\python.exe" -m pip install $b qtpy numpy pytest pillow line_profiler + # PythonQwt itself (editable, current checkout) + & ".\.venvs\$b\Scripts\python.exe" -m pip install -e . + # Optional: needed only by bench_plotpy_loadtest.py + & ".\.venvs\$b\Scripts\python.exe" -m pip install h5py scipy scikit-image opencv-python-headless tqdm +} +``` + +If you keep `PlotPy` and `guidata` as sibling editable checkouts, point `PYTHONPATH` at them when running the PlotPy bench (see workflow 3 below) instead of `pip install`-ing them. + +## Scripts at a glance + +| Script | Use case | Output | +|---|---|---| +| [`bench_qt.ps1`](bench_qt.ps1) | Quick **PythonQwt-only** load test across one or several bindings | `Average elapsed time: ms` per run | +| [`bench_plotpy_loadtest.py`](bench_plotpy_loadtest.py) | The **PlotPy** load test cited in issue #93 | Same format as `bench_qt.ps1` (parser-compatible) | +| [`profile_loadtest.py`](profile_loadtest.py) | First-pass profiling: who eats CPU? (`cProfile`) | Top-40 by cumulative & total time, plus `qwt/`-only view | +| [`lineprofile_loadtest.py`](lineprofile_loadtest.py) | Second-pass profiling: line-by-line on a curated `HOTSPOTS` set (`line_profiler`) | Annotated source listing | +| [`capture_screenshots.py`](capture_screenshots.py) | Run all 22 visual tests, copy PNGs into `shots///` | One PNG per test | +| [`diff_screenshots.py`](diff_screenshots.py) | Pixel-compare two screenshot folders | Markdown table; non-zero exit on `DIFFER` | + +`run_with_env.py` is unrelated to performance work; it is a generic local-development helper used elsewhere in the project. + +## Workflow 1 — "Did I regress performance?" + +Run before *and* after the change you want to validate, on the *same* machine, with no other heavy process competing for the CPU: + +```powershell +.\scripts\bench_qt.ps1 -Repeat 5 # PythonQwt-only micro load test +# Optional, slower, more representative of real-world usage: +$env:PYTHONPATH = "c:\Dev\PlotPy;c:\Dev\guidata" +foreach ($b in "pyqt5","pyqt6","pyside6") { + & ".\.venvs\$b\Scripts\python.exe" scripts\bench_plotpy_loadtest.py --repeat 3 --nplots 60 +} +``` + +Use the median across the 5 runs (the first run is usually slower due to warm-up) and **compare across all three bindings**: an optimization that helps PyQt5 only, or that helps PyQt6 only, is rarely a good trade. + +## Workflow 2 — "Where is time being spent?" + +Two-pass approach. cProfile first (cheap, broad), then line_profiler on the families it surfaces (focused, deep): + +```powershell +# First pass: who eats CPU? +$env:QT_API = "pyside6" +& .\.venvs\pyside6\Scripts\python.exe scripts\profile_loadtest.py pyside6.prof + +# Second pass: which line of which method? Edit HOTSPOTS in lineprofile_loadtest.py +# to add/remove functions of interest, then: +& .\.venvs\pyside6\Scripts\python.exe scripts\lineprofile_loadtest.py > lineprofile.txt +``` + +Comparing the cProfile output of two bindings (PyQt5 vs PySide6 typically) is the fastest way to spot Python-side overhead that the Qt6 bindings amplify; that diff is what guided the cleanups in [issue #93](https://github.com/PlotPyStack/PythonQwt/issues/93). + +## Workflow 3 — "Did I regress rendered output?" + +```powershell +# 1. Capture before (master) and after (your branch) for each binding. +$env:QT_API = "pyqt5" +& .\.venvs\pyqt5\Scripts\python.exe scripts\capture_screenshots.py shots\master\pyqt5 +# ... checkout your branch, repeat ... +& .\.venvs\pyqt5\Scripts\python.exe scripts\capture_screenshots.py shots\fix\pyqt5 + +# 2. (Recommended) capture each side TWICE (master-vs-master2 and fix-vs-fix2) +# to identify *flaky* tests that have intrinsically random output. Otherwise +# the diff cannot tell a regression from random data. + +# 3. Diff. PIL/numpy do not depend on Qt, so any venv with them works. +& .\.venvs\pyside6\Scripts\python.exe scripts\diff_screenshots.py shots\master\pyqt5 shots\fix\pyqt5 +``` + +After running `capture_screenshots.py`, the test PNGs accumulated under `qwt/tests/data/` should be cleaned up — only the tracked PNGs are kept (one-liner): + +```powershell +git checkout -- qwt/tests/data +$tracked = git ls-files qwt/tests/data/*.png | ForEach-Object { Split-Path $_ -Leaf } +Get-ChildItem qwt\tests\data\*.png | Where-Object { $_.Name -notin $tracked } | Remove-Item +``` + +The classification rule used in the issue #93 summary (✅ / ⚠️ / ❌) crosses two diff runs per test (master self-compare baseline + master-vs-fix). It is described in detail in [`doc/issue93_optimization_summary.md`](../doc/issue93_optimization_summary.md#per-test-screenshot-status-master-vs-phase-2-fix-all-bindings). + +## Reference numbers (from issue #93) + +So future investigations have a yardstick. Windows 11, Python 3.11.9, real desktop session (not `offscreen`). + +PythonQwt micro `test_loadtest` (`scripts/bench_qt.ps1 -Repeat 5`): + +| Binding | Master at the time | After issue #93 | +|---|---:|---:| +| PyQt5 | ~1 900 ms | ~450–550 ms | +| PyQt6 | ~2 300 ms | ~450–675 ms | +| PySide6 | ~2 900 ms | ~580–795 ms | + +PlotPy `test_loadtest`, 60 plots (`scripts/bench_plotpy_loadtest.py --repeat 3 --nplots 60`): + +| Binding | Master at the time | After issue #93 | +|---|---:|---:| +| PyQt5 | 25 134 ms | 16 169 ms | +| PyQt6 | 42 202 ms | 21 387 ms | +| PySide6 | 53 160 ms | 24 849 ms | + +If your absolute numbers differ by more than ~30% from these on a typical dev machine, suspect environmental drift before assuming a regression / improvement. + +## See also + +- [`doc/issue93_optimization_summary.md`](../doc/issue93_optimization_summary.md) — the case study these scripts came out of. +- [Issue #93](https://github.com/PlotPyStack/PythonQwt/issues/93) on GitHub. diff --git a/scripts/bench_plotpy_loadtest.py b/scripts/bench_plotpy_loadtest.py new file mode 100644 index 0000000..c0f0ca1 --- /dev/null +++ b/scripts/bench_plotpy_loadtest.py @@ -0,0 +1,94 @@ +"""Benchmark PlotPy's load test (test_loadtest) against the current PythonQwt. + +This reproduces the load test cited in the PlotPy/PythonQwt performance issue +(https://github.com/PlotPyStack/PythonQwt/issues/93): instantiating a large +grid of plot widgets and measuring construction time. See also +``scripts/README.md`` and ``doc/issue93_optimization_summary.md``. + +Prerequisites +------------- +A Python interpreter with **all** of the following importable: + +* a Qt binding (``PyQt5``, ``PyQt6`` or ``PySide6``) selected via ``QT_API``; +* ``qtpy``, ``numpy``, plus the usual PlotPy stack (``h5py``, ``scipy``, + ``scikit-image``, ``opencv-python-headless``, ``tqdm``); +* ``plotpy`` and ``guidata`` (typically as editable installs from sibling + checkouts: set ``PYTHONPATH=<...>\\PlotPy;<...>\\guidata``); +* the ``PythonQwt`` checkout under test (current working directory or in the + same ``PYTHONPATH``). + +The script does **not** force ``QT_QPA_PLATFORM=offscreen``: numbers are taken +with the real Qt paint pipeline so they include composite cost. + +Usage +----- +:: + + # PowerShell, with the PyQt5 venv prepared as described in scripts/README.md + $env:QT_API = "pyqt5" + $env:PYTHONPATH = "c:\\Dev\\PlotPy;c:\\Dev\\guidata" + & .\\.venvs\\pyqt5\\Scripts\\python.exe scripts\bench_plotpy_loadtest.py --repeat 3 --nplots 60 + +Output contains a line compatible with ``scripts/bench_qt.ps1``'s parser:: + + Average elapsed time: ms +""" + +from __future__ import annotations + +import argparse +import os +import time + +# Avoid PlotPy's "first run" wizard / dialogs in headless mode +os.environ.setdefault("GUIDATA_TEST_MODE", "1") + + +def run_once(nplots: int, ncols: int, nrows: int) -> float: + """Run one LoadTest construction and return elapsed seconds.""" + # Imports happen inside the function so the Qt binding is fully selected + # via QT_API by the time PlotPy / guidata import qtpy. + from guidata.qthelpers import qt_app_context # noqa: WPS433 + from plotpy.tests.benchmarks.test_loadtest import LoadTest # noqa: WPS433 + from qtpy import QtWidgets as QW # noqa: WPS433 + + with qt_app_context(exec_loop=False): + t0 = time.perf_counter() + win = LoadTest(nplots=nplots, ncols=ncols, nrows=nrows) + win.show() + QW.QApplication.processEvents() + elapsed = time.perf_counter() - t0 + win.close() + return elapsed + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--repeat", type=int, default=3) + parser.add_argument("--nplots", type=int, default=60) + parser.add_argument("--ncols", type=int, default=6) + parser.add_argument("--nrows", type=int, default=5) + args = parser.parse_args() + + # Print binding banner like PythonQwt's own loadtest does + import sys + + from qtpy import API_NAME, PYQT_VERSION, QT_VERSION # noqa: WPS433 + + pyver = ".".join(str(v) for v in sys.version_info[:3]) + print( + f"PlotPy load test [Python {pyver}, Qt {QT_VERSION}, " + f"{API_NAME} v{PYQT_VERSION}, nplots={args.nplots}]" + ) + + times = [] + for i in range(args.repeat): + t = run_once(args.nplots, args.ncols, args.nrows) + times.append(t) + print(f" run {i + 1}/{args.repeat}: {t * 1000:.0f} ms") + avg_ms = sum(times) / len(times) * 1000 + print(f"Average elapsed time: {avg_ms:.0f} ms") + + +if __name__ == "__main__": + main() diff --git a/scripts/bench_qt.ps1 b/scripts/bench_qt.ps1 new file mode 100644 index 0000000..ba99c9d --- /dev/null +++ b/scripts/bench_qt.ps1 @@ -0,0 +1,66 @@ +# Run the PythonQwt micro load test (qwt/tests/test_loadtest.py) across one +# or several Qt bindings, using per-binding venvs under .venvs//. +# +# Primary use case: detect performance regressions in PythonQwt itself, +# isolated from PlotPy. For the PlotPy load test (the one cited in +# https://github.com/PlotPyStack/PythonQwt/issues/93) use +# scripts/bench_plotpy_loadtest.py instead. +# +# See scripts/README.md and doc/issue93_optimization_summary.md for the +# full performance-investigation workflow and reference numbers. +# +# Prerequisites: +# .venvs//Scripts/python.exe must exist for each binding listed in +# $Bindings, with the corresponding Qt binding + numpy + qtpy + pytest +# installed, and PythonQwt installed in editable mode (pip install -e .). +# See scripts/README.md for a one-shot bootstrap snippet. +# +# Usage: +# .\scripts\bench_qt.ps1 # run all three bindings, 1 run each +# .\scripts\bench_qt.ps1 pyqt5 # run a single binding +# .\scripts\bench_qt.ps1 pyqt5,pyside6 # run a subset +# .\scripts\bench_qt.ps1 -Repeat 5 # repeat each run N times (recommended) +# +# The script sets PYTHONQWT_UNATTENDED_TESTS=1 and QT_API=, then +# invokes qwt/tests/test_loadtest.py via the binding-specific venv. It +# captures the "Average elapsed time" line printed by the benchmark. + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string[]] $Bindings = @("pyqt5", "pyqt6", "pyside6"), + [int] $Repeat = 1 +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + foreach ($binding in $Bindings) { + $py = Join-Path $repoRoot ".venvs\$binding\Scripts\python.exe" + if (-not (Test-Path $py)) { + Write-Warning "Skipping $binding (venv not found at $py)" + continue + } + $env:PYTHONQWT_UNATTENDED_TESTS = "1" + $env:QT_API = $binding + for ($i = 1; $i -le $Repeat; $i++) { + $output = & $py "qwt\tests\test_loadtest.py" 2>&1 + $avg = $output | Select-String -Pattern "Average elapsed time" | Select-Object -Last 1 + $tag = "[{0}]" -f $binding + if ($Repeat -gt 1) { $tag = "{0} run {1}/{2}" -f $tag, $i, $Repeat } + if ($avg) { + Write-Host ("{0} {1}" -f $tag, $avg.Line.Trim()) + } + else { + Write-Host ("{0} (no result)" -f $tag) + Write-Host ($output -join [Environment]::NewLine) + } + } + Remove-Item Env:PYTHONQWT_UNATTENDED_TESTS -ErrorAction SilentlyContinue + Remove-Item Env:QT_API -ErrorAction SilentlyContinue + } +} +finally { + Pop-Location +} diff --git a/scripts/capture_screenshots.py b/scripts/capture_screenshots.py new file mode 100644 index 0000000..ed96552 --- /dev/null +++ b/scripts/capture_screenshots.py @@ -0,0 +1,162 @@ +"""Capture screenshots from every PythonQwt visual test into a directory. + +Used together with :mod:`diff_screenshots` to detect visual regressions +introduced by performance optimizations. See ``scripts/README.md`` and +``doc/issue93_optimization_summary.md`` for the broader workflow. + +What it does +------------ +For each entry in the hard-coded ``tests`` list, runs the corresponding test +in a *subprocess* with ``PYTHONQWT_TAKE_SCREENSHOTS=1`` and +``PYTHONQWT_UNATTENDED_TESTS=1`` set, watches the test data directory for +newly-written PNGs and copies them into ```` with names of the +form ``__.png`` (so different tests producing the +same PNG basename do not overwrite each other). + +Prerequisites +------------- +* A Qt binding selected via ``QT_API`` (``pyqt5`` / ``pyqt6`` / ``pyside6``). +* PythonQwt importable (typically the editable install of the local checkout). +* The Qt platform plug-in able to render to a real display surface + (offscreen also works but produces slightly different antialiasing). + +Usage +----- +:: + + $env:QT_API = "pyqt5" + & .\\.venvs\\pyqt5\\Scripts\\python.exe scripts\\capture_screenshots.py shots\fix\\pyqt5 + +The output directory is created if missing. After the run, leftover PNGs in +``qwt/tests/data/`` should be cleaned up (only newly-untracked PNGs are +produced by tests; see ``scripts/README.md`` for the cleanup snippet). + +Note: the ``tests`` list maps each test *module* (``test_xxx``) to the *test +function* defined inside it. Some modules use a slightly different function +name (``test_relativemargin`` -> ``test_relative_margin``, +``test_symbols`` -> ``test_base``); update this mapping if new visual tests +are added. +""" + +from __future__ import annotations + +import os +import os.path as osp +import shutil +import subprocess +import sys +import time +import traceback + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + out_dir = osp.abspath(sys.argv[1]) + os.makedirs(out_dir, exist_ok=True) + + os.environ["PYTHONQWT_TAKE_SCREENSHOTS"] = "1" + os.environ["PYTHONQWT_UNATTENDED_TESTS"] = "1" + + import qwt # noqa: F401 + from qwt.tests import utils as test_utils + + data_dir = osp.join(test_utils.TEST_PATH, "data") + + tests = [ + ("test_backingstore", "test_backingstore"), + ("test_bodedemo", "test_bodedemo"), + ("test_cartesian", "test_cartesian"), + ("test_cpudemo", "test_cpudemo"), + ("test_curvebenchmark1", "test_curvebenchmark1"), + ("test_curvebenchmark2", "test_curvebenchmark2"), + ("test_curvedemo1", "test_curvedemo1"), + ("test_curvedemo2", "test_curvedemo2"), + ("test_data", "test_data"), + ("test_errorbar", "test_errorbar"), + ("test_eventfilter", "test_eventfilter"), + ("test_highdpi", "test_highdpi"), + ("test_image", "test_image"), + ("test_loadtest", "test_loadtest"), + ("test_logcurve", "test_logcurve"), + ("test_mapdemo", "test_mapdemo"), + ("test_multidemo", "test_multidemo"), + ("test_relativemargin", "test_relative_margin"), + ("test_simple", "test_simple"), + ("test_stylesheet", "test_stylesheet"), + ("test_symbols", "test_base"), + ("test_vertical", "test_vertical"), + ] + + failed: list[str] = [] + no_screenshot: list[str] = [] + + for module_name, func_name in tests: + before = { + f: os.path.getmtime(osp.join(data_dir, f)) + for f in os.listdir(data_dir) + if f.lower().endswith(".png") + } + marker = time.time() + + cmd = [ + sys.executable, + "-c", + f"import qwt.tests.{module_name} as m; m.{func_name}()", + ] + try: + proc = subprocess.run( + cmd, + env=os.environ.copy(), + capture_output=True, + text=True, + timeout=180, + ) + except subprocess.TimeoutExpired: + print(f"[TIMEOUT] {module_name}") + failed.append(module_name) + continue + + if proc.returncode != 0: + print(f"[FAIL] {module_name}: rc={proc.returncode}") + if proc.stderr: + print(proc.stderr.strip()[-500:]) + failed.append(module_name) + continue + + produced = [] + for f in os.listdir(data_dir): + if not f.lower().endswith(".png"): + continue + full = osp.join(data_dir, f) + mt = os.path.getmtime(full) + if mt >= marker - 0.5 and (f not in before or mt > before[f] + 0.001): + produced.append(f) + + if not produced: + print(f"[NO SCREENSHOT] {module_name}") + no_screenshot.append(module_name) + continue + + for png in produced: + src = osp.join(data_dir, png) + dst = osp.join(out_dir, f"{module_name}__{png}") + shutil.copy2(src, dst) + print(f"[OK] {module_name} -> {osp.basename(dst)}") + + print() + print(f"Captured screenshots into: {out_dir}") + if failed: + print(f"Failed ({len(failed)}): {', '.join(failed)}") + if no_screenshot: + print(f"No screenshot ({len(no_screenshot)}): {', '.join(no_screenshot)}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + traceback.print_exc() + sys.exit(1) diff --git a/scripts/diff_screenshots.py b/scripts/diff_screenshots.py new file mode 100644 index 0000000..dfe06b5 --- /dev/null +++ b/scripts/diff_screenshots.py @@ -0,0 +1,121 @@ +"""Pixel-compare two directories of screenshots produced by +:mod:`capture_screenshots`. See ``scripts/README.md`` and +``doc/issue93_optimization_summary.md`` for the broader workflow. + +For each PNG present in both directories, classifies the pair as one of: + +* ``IDENTICAL`` - byte-equal files; +* ``EQUAL_PIXELS`` - bytes differ but decoded pixel arrays match; +* ``DIFFER`` - size or pixel mismatch (with ``n/total`` differing pixels, + pixel percentage, max and mean per-channel magnitude); +* ``ERROR`` - PIL failed to decode one of the files. + +Files present in only one side are listed at the end. The exit code is ``0`` +if and only if no ``DIFFER`` row is produced (useful as a CI gate). + +A ``DIFFER`` baseline does not necessarily mean a regression: some PythonQwt +visual tests use random data, timestamps or live system stats and are +intrinsically non-reproducible. Always pair the run with a self-compare +baseline (e.g. ``shots/master/`` vs ``shots/master2/``) to +identify *flaky* tests; see the optimization summary for the classification +rule ("✅ / ⚠️ / ❌"). + +Prerequisites +------------- +``numpy`` and ``Pillow`` available in the Python that runs the comparison +(any binding-specific venv works - PIL/numpy do not depend on Qt). + +Usage +----- +:: + + python scripts/diff_screenshots.py shots\\master\\pyqt5 shots\fix\\pyqt5 + +Output is a Markdown table; pipe through ``Select-String DIFFER|Summary`` to +focus on regressions only. +""" + +from __future__ import annotations + +import os +import os.path as osp +import sys + +import numpy as np +from PIL import Image + + +def load_array(path): + img = Image.open(path).convert("RGBA") + return np.asarray(img) + + +def main() -> int: + if len(sys.argv) != 3: + print(__doc__) + return 2 + a, b = osp.abspath(sys.argv[1]), osp.abspath(sys.argv[2]) + files_a = {f for f in os.listdir(a) if f.lower().endswith(".png")} + files_b = {f for f in os.listdir(b) if f.lower().endswith(".png")} + common = sorted(files_a & files_b) + only_a = sorted(files_a - files_b) + only_b = sorted(files_b - files_a) + + rows = [] + for name in common: + pa, pb = osp.join(a, name), osp.join(b, name) + with open(pa, "rb") as f: + ba = f.read() + with open(pb, "rb") as f: + bb = f.read() + if ba == bb: + rows.append((name, "IDENTICAL", "", "")) + continue + try: + arr_a = load_array(pa) + arr_b = load_array(pb) + except Exception as exc: + rows.append((name, "ERROR", str(exc), "")) + continue + if arr_a.shape != arr_b.shape: + rows.append((name, "DIFFER", f"shape {arr_a.shape} vs {arr_b.shape}", "")) + continue + diff = np.abs(arr_a.astype(np.int16) - arr_b.astype(np.int16)) + n_diff = int(np.any(diff > 0, axis=-1).sum()) + total = arr_a.shape[0] * arr_a.shape[1] + pct = 100.0 * n_diff / total + max_diff = int(diff.max()) + mean_diff = float(diff.mean()) + if n_diff == 0: + rows.append((name, "EQUAL_PIXELS", "", "")) + else: + rows.append( + ( + name, + "DIFFER", + f"{n_diff}/{total} px ({pct:.2f}%)", + f"max={max_diff} mean={mean_diff:.2f}", + ) + ) + + # Print a markdown-style table + print(f"# Screenshot diff: {a} vs {b}") + print() + print("| Test (PNG) | Status | Pixels differ | Magnitude |") + print("|---|---|---|---|") + for name, status, info1, info2 in rows: + print(f"| `{name}` | {status} | {info1} | {info2} |") + + if only_a: + print(f"\nOnly in A: {', '.join(only_a)}") + if only_b: + print(f"\nOnly in B: {', '.join(only_b)}") + + n_diff = sum(1 for r in rows if r[1] == "DIFFER") + n_same = sum(1 for r in rows if r[1] in ("IDENTICAL", "EQUAL_PIXELS")) + print(f"\nSummary: {n_same} match, {n_diff} differ, {len(common)} total") + return 0 if n_diff == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/lineprofile_loadtest.py b/scripts/lineprofile_loadtest.py new file mode 100644 index 0000000..cdba522 --- /dev/null +++ b/scripts/lineprofile_loadtest.py @@ -0,0 +1,92 @@ +r"""Line-profile a curated set of PythonQwt hot functions under the load test. + +Second-pass tool for performance investigation, used after +:mod:`profile_loadtest` has identified the hot families. Line-by-line +attribution often surfaces costs that are invisible to ``cProfile`` (e.g. a +single ``QFont.key()`` call inside a tight loop, or per-call QObject overhead +on Qt6). See ``scripts/README.md`` and ``doc/issue93_optimization_summary.md``. + +What it does +------------ +Runs ``qwt.tests.test_loadtest`` once with :mod:`line_profiler` instrumenting +each function listed in :data:`HOTSPOTS`, then prints the line-by-line stats +on stdout (units: microseconds). + +The ``HOTSPOTS`` mapping is a *curated* registry. When the surface area of an +optimization changes, **edit the mapping in this file** to add/remove +functions; passing names on the command-line only restricts which of the +registered hotspots are profiled in this run. + +Prerequisites +------------- +* A Qt binding selected via ``QT_API`` (``pyqt5`` / ``pyqt6`` / ``pyside6``). +* ``line_profiler`` (``pip install line_profiler``). +* PythonQwt importable. + +Usage +----- +:: + + # Full hotspot set + & .\.venvs\pyside6\Scripts\python.exe scripts\lineprofile_loadtest.py + + # Subset (whatever you are currently iterating on) + & .\.venvs\pyside6\Scripts\python.exe scripts\lineprofile_loadtest.py textSize labelRect + +Redirect to a file when iterating: ``> lineprofile.txt`` then diff between +commits to confirm a hot line dropped from N us to N/2. +""" + +from __future__ import annotations + +import os +import sys + +os.environ.setdefault("PYTHONQWT_UNATTENDED_TESTS", "1") + +from line_profiler import LineProfiler # noqa: E402 + +import qwt.scale_div # noqa: E402 +import qwt.scale_draw # noqa: E402 +import qwt.scale_engine # noqa: E402 +import qwt.scale_map # noqa: E402 +import qwt.text # noqa: E402 +from qwt.tests import test_loadtest # noqa: E402 + +# (module, qualified-name) — only methods listed here are line-profiled. +HOTSPOTS = { + "textSize": qwt.text.QwtText.textSize, + "textEngine": qwt.text.QwtText.textEngine, + "QwtText.__init__": qwt.text.QwtText.__init__, + "PlainTextEngine.textMargins": qwt.text.QwtPlainTextEngine.textMargins, + "labelRect": qwt.scale_draw.QwtScaleDraw.labelRect, + "labelPosition": qwt.scale_draw.QwtScaleDraw.labelPosition, + "labelTransformation": qwt.scale_draw.QwtScaleDraw.labelTransformation, + "getBorderDistHint": qwt.scale_draw.QwtScaleDraw.getBorderDistHint, + "draw": qwt.scale_draw.QwtScaleDraw.draw, + "drawLabel": qwt.scale_draw.QwtScaleDraw.drawLabel, + "drawTick": qwt.scale_draw.QwtScaleDraw.drawTick, + "drawBackbone": qwt.scale_draw.QwtScaleDraw.drawBackbone, + "scale_map.transform": qwt.scale_map.QwtScaleMap.transform, + "scale_engine.strip": qwt.scale_engine.QwtScaleEngine.strip, + "scale_engine.contains": qwt.scale_engine.QwtScaleEngine.contains, + "scale_div.contains": qwt.scale_div.QwtScaleDiv.contains, + "orientation": qwt.scale_draw.QwtScaleDraw.orientation, +} + + +def main() -> None: + selected = sys.argv[1:] or list(HOTSPOTS) + profiler = LineProfiler() + for name in selected: + if name not in HOTSPOTS: + print(f"Unknown hotspot: {name!r}", file=sys.stderr) + continue + profiler.add_function(HOTSPOTS[name]) + + profiler.runcall(test_loadtest.test_loadtest) + profiler.print_stats(stream=sys.stdout, output_unit=1e-6) + + +if __name__ == "__main__": + main() diff --git a/scripts/profile_loadtest.py b/scripts/profile_loadtest.py new file mode 100644 index 0000000..a49cbd4 --- /dev/null +++ b/scripts/profile_loadtest.py @@ -0,0 +1,78 @@ +r"""Profile ``qwt.tests.test_loadtest`` under :mod:`cProfile`. + +First-pass tool for performance investigation: identifies which functions +dominate cumulative time and total time. Use :mod:`lineprofile_loadtest` for +the second pass once a hot family of functions has been spotted. See +``scripts/README.md`` and ``doc/issue93_optimization_summary.md``. + +What it does +------------ +Runs the PythonQwt micro load test once under ``cProfile``, then prints three +reports to stdout (and dumps the raw stats to ````): + +1. Top 40 by cumulative time (``--cumulative``). +2. Top 40 by total time (``--tottime``). +3. Top 40 by total time, restricted to ``qwt/`` internals. + +Prerequisites +------------- +* A Qt binding selected via ``QT_API`` (``pyqt5`` / ``pyqt6`` / ``pyside6``); + numbers are most informative when collected for each binding in turn. +* PythonQwt importable. + +Usage +----- +:: + + $env:QT_API = "pyside6" + & .\.venvs\pyside6\Scripts\python.exe scripts\profile_loadtest.py pyside6.prof + +Open the dumped ``.prof`` file with ``snakeviz`` (``pip install snakeviz``) +or ``gprof2dot`` for a graphical view. Diff the per-binding reports to spot +overhead that scales with binding cost. +""" + +from __future__ import annotations + +import cProfile +import os +import pstats +import sys +from io import StringIO + + +def main() -> int: + os.environ["PYTHONQWT_UNATTENDED_TESTS"] = "1" + + # Import lazily so PYTHONQWT_UNATTENDED_TESTS is honored + from qwt.tests import test_loadtest + + pr = cProfile.Profile() + pr.enable() + test_loadtest.test_loadtest() + pr.disable() + + out_path = sys.argv[1] if len(sys.argv) > 1 else "profile.out" + pr.dump_stats(out_path) + + buf = StringIO() + stats = pstats.Stats(pr, stream=buf).sort_stats("cumulative") + stats.print_stats(40) + print(buf.getvalue()) + + buf2 = StringIO() + stats2 = pstats.Stats(pr, stream=buf2).sort_stats("tottime") + stats2.print_stats(40) + print("==== TOTTIME TOP 40 ====") + print(buf2.getvalue()) + + buf3 = StringIO() + stats3 = pstats.Stats(pr, stream=buf3).sort_stats("tottime") + stats3.print_stats(r"qwt[\\/].*", 40) + print("==== TOTTIME (qwt only) ====") + print(buf3.getvalue()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_with_env.py b/scripts/run_with_env.py new file mode 100644 index 0000000..6688cb6 --- /dev/null +++ b/scripts/run_with_env.py @@ -0,0 +1,210 @@ +# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. + +"""Run a command with environment variables loaded from a .env file. + +This script automatically detects the best Python interpreter to use: + +1. ``PYTHON`` variable in ``.env`` file (e.g. for WinPython distributions) +2. ``WINPYDIRBASE`` variable (legacy WinPython base directory) +3. ``VENV_DIR`` variable (explicit virtual environment directory) +4. A local virtual environment (``.venv*`` directory in the project root) +5. Falls back to ``sys.executable`` (the Python that launched this script) + +This ensures that VS Code tasks always use the correct Python environment +regardless of which interpreter is configured globally or in VS Code. +""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + + +def _find_venv_python(project_root: Path) -> str | None: + """Find a Python executable in a ``.venv*`` directory. + + Searches for directories matching ``.venv*`` in the project root and + returns the first valid Python executable found. + + Args: + project_root: The root directory of the project. + + Returns: + Absolute path to the venv Python executable, or None if not found. + """ + # Sort to prefer ".venv" over ".venv-xyz" etc. + venv_dirs = sorted(glob.glob(str(project_root / ".venv*"))) + for venv_dir in venv_dirs: + venv_path = Path(venv_dir) + if not venv_path.is_dir(): + continue + result = _get_venv_python(venv_path) + if result: + return result + return None + + +def _get_venv_python(venv_dir: Path) -> str | None: + """Get the Python executable from a specific venv directory. + + Args: + venv_dir: Path to the virtual environment directory. + + Returns: + Absolute path to the Python executable, or None if not found. + """ + if not venv_dir.is_dir(): + return None + # Windows: Scripts/python.exe — Unix: bin/python + candidates = [ + venv_dir / "Scripts" / "python.exe", + venv_dir / "bin" / "python", + ] + for candidate in candidates: + if candidate.is_file(): + # Keep the venv-local executable path without resolving symlinks: + # on Linux/WSL, ``bin/python`` is often a symlink to a global + # interpreter (e.g. /usr/bin/python3.x). Resolving it would lose + # venv context and site-packages selection. + return str(candidate.absolute()) + return None + + +def resolve_python(project_root: Path) -> str: + """Resolve the best Python interpreter for the project. + + Priority order: + + 1. ``PYTHON`` environment variable (set in ``.env`` or externally) + 2. ``WINPYDIRBASE`` environment variable (legacy WinPython base directory) + 3. ``VENV_DIR`` environment variable (explicit venv directory) + 4. ``.venv*`` directory in *project_root* (auto-discovery) + 5. ``sys.executable`` (the interpreter running this script) + + Args: + project_root: The root directory of the project. + + Returns: + Absolute path to the Python executable to use. + """ + # 1. Explicit PYTHON variable (e.g. WinPython distribution) + python_env = os.environ.get("PYTHON") + if python_env: + python_path = Path(python_env) + if python_path.is_file(): + # Do not resolve symlinks for the same reason as in + # ``_get_venv_python``. + resolved = str(python_path.absolute()) + print(f" 🐍 Using PYTHON from .env: {resolved}") + return resolved + print(f" ⚠️ PYTHON variable set but not found: {python_env}") + + # 2. Legacy WINPYDIRBASE variable (WinPython distribution) + winpy_base = os.environ.get("WINPYDIRBASE") + if winpy_base and Path(winpy_base).is_dir(): + # Search for python.exe in the WinPython directory structure + # Patterns: python-3.11.5.amd64/python.exe (old) or python/python.exe (new) + for pattern in ("python-*/python.exe", "python/python.exe"): + for candidate in sorted(Path(winpy_base).glob(pattern)): + if candidate.is_file(): + resolved = str(candidate.absolute()) + print(f" 🐍 Using WINPYDIRBASE (legacy): {resolved}") + return resolved + # Also try direct python.exe in the base directory + direct = Path(winpy_base) / "python.exe" + if direct.is_file(): + resolved = str(direct.absolute()) + print(f" 🐍 Using WINPYDIRBASE (legacy): {resolved}") + return resolved + print(f" ⚠️ WINPYDIRBASE set but no Python found in: {winpy_base}") + + # 3. Explicit VENV_DIR variable (e.g. for multiple local venvs) + venv_dir_env = os.environ.get("VENV_DIR") + if venv_dir_env: + venv_dir = Path(venv_dir_env) + if not venv_dir.is_absolute(): + venv_dir = project_root / venv_dir + venv_python = _get_venv_python(venv_dir) + if venv_python: + print(f" 🐍 Using VENV_DIR from .env: {venv_python}") + return venv_python + print(f" ⚠️ VENV_DIR set but no Python found in: {venv_dir}") + + # 4. Auto-discover local venv + venv_python = _find_venv_python(project_root) + if venv_python: + print(f" 🐍 Using venv Python: {venv_python}") + return venv_python + + # 5. Fallback + print(f" 🐍 Using caller Python: {sys.executable}") + return sys.executable + + +def load_env_file(env_path: str | None = None) -> None: + """Load environment variables from a .env file.""" + if env_path is None: + env_path = Path.cwd() / ".env" + if not Path(env_path).is_file(): + raise FileNotFoundError(f"Environment file not found: {env_path}") + print(f"Loading environment variables from: {env_path}") + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip().strip('"').strip("'") + os.environ[key.strip()] = value + print(f" Loaded variable: {key.strip()}={value}") + + +def execute_command(command: list[str], python_exe: str) -> int: + """Execute a command, replacing ``python`` placeholders. + + Any argument that is the bare word ``python`` or that points to a Python + executable (checked via filename) is replaced by *python_exe* so that the + subprocess uses the resolved interpreter rather than the global one. + + Args: + command: The command and its arguments. + python_exe: The resolved Python interpreter path. + + Returns: + The subprocess exit code. + """ + resolved: list[str] = [] + for arg in command: + if arg.lower() == "python" or ( + Path(arg).name.lower().startswith("python") + and Path(arg).is_file() + and arg.lower() != python_exe.lower() + ): + resolved.append(python_exe) + else: + resolved.append(arg) + print("Executing command:") + print(" ".join(resolved)) + print("") + result = subprocess.call(resolved) + print(f"Process exited with code {result}") + return result + + +def main() -> None: + """Main function to load environment variables and execute a command.""" + if len(sys.argv) < 2: + print("Usage: python run_with_env.py [args ...]") + sys.exit(1) + print("🏃 Running with environment variables") + project_root = Path.cwd() + load_env_file() + python_exe = resolve_python(project_root) + return execute_command(sys.argv[1:], python_exe) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py deleted file mode 100644 index 2cb16e3..0000000 --- a/setup.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Licensed under the terms of the MIT License -# Copyright (c) 2015 Pierre Raybaut -# (see LICENSE file for more details) - -""" -python-qwt -========== - -Qt plotting widgets for Python -""" - -from __future__ import print_function - -import os -import sys -import os.path as osp - -import setuptools # analysis:ignore -from distutils.core import setup -from distutils.command.build import build - -LIBNAME = 'python-qwt' -PACKAGE_NAME = 'qwt' -from qwt import __version__ as version - -DESCRIPTION = 'Qt plotting widgets for Python' -LONG_DESCRIPTION = """\ -The ``python-qwt`` project is a pure Python translation of the Qwt C++ library -which implements Qt widgets for plotting curves. -It consists of a single Python package named `qwt` (and examples, doc, ...). - -The ``python-qwt`` project was initiated to solve -at least temporarily- the -obsolescence issue of `PyQwt` (the Python-Qwt C++ bindings library) which is -no longer maintained. The idea was to translate the Qwt C++ code to Python and -then to optimize some parts of the code by writing new modules based on NumPy -and other libraries. - -The following ``Qwt`` classes won't be reimplemented in ``python-qwt`` because -most powerful features already exist in ``guiqwt``: QwtCounter, QwtPicker, -QwtPlotPicker, QwtPlotZoomer and QwtEventPattern. -QwtClipper is not implemented (and it will probably be very difficult or -impossible to implement it in pure Python without performance issues). As a -consequence, when zooming in a plot curve, the entire curve is still painted -(in other words, when working with large amount of data, there is no -performance gain when zooming in).""" -KEYWORDS = '' -CLASSIFIERS = [] -if 'beta' in version or 'b' in version: - CLASSIFIERS += ['Development Status :: 4 - Beta'] -elif 'alpha' in version or 'a' in version: - CLASSIFIERS += ['Development Status :: 3 - Alpha'] -else: - CLASSIFIERS += ['Development Status :: 5 - Production/Stable'] - - -def get_package_data(name, extlist): - """Return data files for package *name* with extensions in *extlist*""" - flist = [] - # Workaround to replace os.path.relpath (not available until Python 2.6): - offset = len(name)+len(os.pathsep) - for dirpath, _dirnames, filenames in os.walk(name): - for fname in filenames: - if not fname.startswith('.') and osp.splitext(fname)[1] in extlist: - flist.append(osp.join(dirpath, fname)[offset:]) - return flist - - -def get_subpackages(name): - """Return subpackages of package *name*""" - splist = [] - for dirpath, _dirnames, _filenames in os.walk(name): - if osp.isfile(osp.join(dirpath, '__init__.py')): - splist.append(".".join(dirpath.split(os.sep))) - return splist - - -try: - import sphinx -except ImportError: - sphinx = None - -from distutils.command.build import build as dftbuild - -class build(dftbuild): - def has_doc(self): - if sphinx is None: - return False - setup_dir = os.path.dirname(os.path.abspath(__file__)) - return os.path.isdir(os.path.join(setup_dir, 'doc')) - sub_commands = dftbuild.sub_commands + [('build_doc', has_doc)] - -cmdclass = {'build' : build} - -if sphinx: - from sphinx.setup_command import BuildDoc - class build_doc(BuildDoc): - def run(self): - # make sure the python path is pointing to the newly built - # code so that the documentation is built on this and not a - # previously installed version - build = self.get_finalized_command('build') - sys.path.insert(0, os.path.abspath(build.build_lib)) - try: - sphinx.setup_command.BuildDoc.run(self) - except UnicodeDecodeError: - print("ERROR: unable to build documentation because Sphinx do not handle source path with non-ASCII characters. Please try to move the source package to another location (path with *only* ASCII characters).", file=sys.stderr) - sys.path.pop(0) - - cmdclass['build_doc'] = build_doc - - -setup(name=LIBNAME, version=version, - description=DESCRIPTION, long_description=LONG_DESCRIPTION, - packages=get_subpackages(PACKAGE_NAME), - package_data={PACKAGE_NAME: - get_package_data(PACKAGE_NAME, ('.png', '.svg', '.mo'))}, - requires=["PyQt4 (>4.3)",], - author = "Pierre Raybaut", - author_email = 'pierre.raybaut@gmail.com', - url = 'https://github.com/PierreRaybaut/%s' % LIBNAME, - platforms = 'Any', - classifiers=CLASSIFIERS + [ - 'License :: OSI Approved :: GNU Lesser General Public License v2 (LGPLv2)', - 'License :: OSI Approved :: MIT License', - 'Topic :: Scientific/Engineering :: Visualization', - 'Topic :: Software Development :: Widget Sets', - 'Operating System :: MacOS', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: OS Independent', - 'Operating System :: POSIX', - 'Operating System :: Unix', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - ], - cmdclass=cmdclass) diff --git a/upload.bat b/upload.bat deleted file mode 100644 index fa830d5..0000000 --- a/upload.bat +++ /dev/null @@ -1,5 +0,0 @@ -rmdir /S /Q build -rmdir /S /Q dist -python setup.py build sdist upload -python setup.py sdist bdist_wheel --universal upload -pause \ No newline at end of file