diff --git a/.github/assets/screenshot.png b/.github/assets/screenshot.png new file mode 100644 index 0000000..3a1b5af Binary files /dev/null and b/.github/assets/screenshot.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60a4663 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + # PyQt5 wheels bundle Qt itself but still link against these system + # libraries, which the GitHub runner image does not ship. They are + # needed even for the offscreen platform plugin. + - name: Install Qt system libraries + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libegl1 libgl1 libdbus-1-3 libxkbcommon-x11-0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ + libxcb-render-util0 libxcb-shape0 libxcb-xfixes0 libxcb-xinerama0 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Run tests + env: + QT_QPA_PLATFORM: offscreen + run: pytest + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b9547a --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Byte-compiled / optimised files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / coverage artefacts +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# Editors and OS cruft +.idea/ +.vscode/ +.DS_Store diff --git a/CommonMPL.py b/CommonMPL.py index 250d1eb..3e41fee 100644 --- a/CommonMPL.py +++ b/CommonMPL.py @@ -1,113 +1,83 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Pierre Haessig — March 2014 -""" -Qt adaptation of Gael Varoquaux's tutorial to integrate Matplotlib -http://docs.enthought.com/traitsui/tutorials/traits_ui_scientific_app.html#extending-traitsui-adding-a-matplotlib-figure-to-our-application -based on Qt-based code shared by Didrik Pinte, May 2012 -http://markmail.org/message/z3hnoqruk56g2bje -adapted and tested to work with PySide from Anaconda in March 2014 -""" +#!/usr/bin/env python3 +"""A TraitsUI editor that embeds a matplotlib figure in a Qt5 application. -from pyface.qt import QtGui, QtCore -#from traits.etsconfig.api import ETSConfig -#ETSConfig.toolkit = 'qt4' -# -import matplotlib as mpl -#mpl.rcParams['backend.qt4']='PySide' -# We want matplotlib to use a QT backend -mpl.use('Qt4Agg') -from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure -from matplotlib.backends.backend_qt4agg import NavigationToolbar2QT - -from traits.api import Any, Instance -from traitsui.qt4.editor import Editor -from traitsui.qt4.basic_editor_factory import BasicEditorFactory -from traitsui.api import Handler +Originally a Qt adaptation by Pierre Haessig (March 2014) of Gael Varoquaux's +tutorial on integrating matplotlib with TraitsUI, based on Qt code shared by +Didrik Pinte (May 2012): +* http://docs.enthought.com/traitsui/tutorials/traits_ui_scientific_app.html +* http://markmail.org/message/z3hnoqruk56g2bje +* https://gist.github.com/pierre-haessig/9838326 +Import order matters here. ``pyface.qt`` is imported first so that it resolves +and caches the Qt binding (PyQt5, PySide2, ...); matplotlib then reuses the +already-imported binding instead of independently picking a different one, +which would crash the moment the two tried to share a widget. +""" -from traits.api import * -from traitsui.api import View,UItem, Item, Group, Heading, Label, \ - HSplit, Handler, CheckListEditor, EnumEditor, TableEditor, FileEditor, \ - ListEditor, Tabbed, VGroup, HGroup, RangeEditor, Spring, spring -from traitsui.menu import NoButtons +# isort: off +# Order is deliberate -- see the module docstring. pyface.qt must resolve the +# Qt binding before matplotlib looks for one. Do not let a formatter sort these. +from pyface.qt import QtGui -#from traitsui.wx.editor import Editor -from traitsui.api import ColorTrait +from matplotlib.backends.backend_qtagg import ( + FigureCanvasQTAgg as FigureCanvas, + NavigationToolbar2QT as NavigationToolbar, +) -import numpy as np -from matplotlib import * -import os,sys,platform,socket,random +# isort: on -class _MPLFigureEditor(Editor): +from traitsui.api import Handler +from traitsui.basic_editor_factory import BasicEditorFactory +from traitsui.qt.editor import Editor - scrollable = True +__all__ = ["MPLFigureEditor", "MPLInitHandler"] - def init(self, parent): - self.control = self._create_canvas(parent) - self.set_tooltip() - def update_editor(self): - pass +class _MPLFigureEditor(Editor): + """Wraps a :class:`~matplotlib.figure.Figure` trait in a Qt canvas.""" - def _create_canvas(self, parent): - """ Create the MPL canvas. """ - # matplotlib commands to create a canvas - frame = QtGui.QWidget() - mpl_canvas = FigureCanvas(self.value) - mpl_canvas.setParent(frame) - mpl_toolbar = NavigationToolbar2QT(mpl_canvas,frame) + scrollable = True - vbox = QtGui.QVBoxLayout() - vbox.addWidget(mpl_canvas) - vbox.addWidget(mpl_toolbar) - frame.setLayout(vbox) + def init(self, parent): + self._canvas = None + self.control = self._create_canvas(parent) + self.set_tooltip() - return frame + def update_editor(self): + """Redraw the canvas when the underlying figure trait changes.""" + if self._canvas is not None: + self._canvas.draw_idle() -class MPLFigureEditor(BasicEditorFactory): + def _create_canvas(self, parent): + """Build a frame holding the MPL canvas and its navigation toolbar.""" + frame = QtGui.QWidget() + mpl_canvas = FigureCanvas(self.value) + mpl_canvas.setParent(frame) + self._canvas = mpl_canvas + mpl_toolbar = NavigationToolbar(mpl_canvas, frame) - klass = _MPLFigureEditor + vbox = QtGui.QVBoxLayout() + vbox.addWidget(mpl_canvas) + vbox.addWidget(mpl_toolbar) + frame.setLayout(vbox) -class MPLInitHandler(Handler): - """Handler calls mpl_setup() to initialize mpl events""" - - def init(self, info): - """This method gets called after the controls have all been - created but before they are displayed. - """ - info.object.mpl_setup() - return True + return frame -if __name__ == "__main__": - # Create a window to demo the editor - from traits.api import HasTraits - from traitsui.api import View, Item - from numpy import sin, cos, linspace, pi - from matplotlib.widgets import RectangleSelector - class Test(HasTraits): +class MPLFigureEditor(BasicEditorFactory): + """Editor factory: ``Item('figure', editor=MPLFigureEditor())``.""" - figure = Instance(Figure, ()) + klass = _MPLFigureEditor - view = View(Item('figure', editor=MPLFigureEditor(), - show_label=False), - handler=MPLInitHandler, - resizable=True) - def __init__(self): - super(Test, self).__init__() - self.axes = self.figure.add_subplot(111) - t = linspace(0, 2*pi, 200) - self.axes.plot(sin(t)*(1+0.5*cos(11*t)), cos(t)*(1+0.5*cos(11*t))) +class MPLInitHandler(Handler): + """Calls ``mpl_setup()`` on the view object once its controls exist. - def mpl_setup(self): - def onselect(eclick, erelease): - print("eclick: {}, erelease: {}".format(eclick,erelease)) - - self.rs = RectangleSelector(self.axes, onselect, - drawtype='box',useblit=True) + Attach this to a ``View`` when you need to register matplotlib event + handlers (pickers, selectors) that require a live canvas. + """ - Test().configure_traits() \ No newline at end of file + def init(self, info): + info.object.mpl_setup() + return True diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..55e036a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Brendan Griffen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Python3GUITemplate_macosx.yml b/Python3GUITemplate_macosx.yml deleted file mode 100644 index 1d99153..0000000 --- a/Python3GUITemplate_macosx.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Python3GUITemplate -channels: - - defaults -dependencies: - - blas=1.0=mkl - - ca-certificates=2018.03.07=0 - - certifi=2018.8.24=py35_1 - - cycler=0.10.0=py35hb89929e_0 - - freetype=2.9.1=hb4e5f40_0 - - intel-openmp=2019.1=144 - - kiwisolver=1.0.1=py35h219a9d8_0 - - libcxx=4.0.1=hcfea43d_1 - - libcxxabi=4.0.1=hcfea43d_1 - - libedit=3.1.20170329=hb402a30_2 - - libffi=3.2.1=h475c297_4 - - libgfortran=3.0.1=h93005f0_2 - - libpng=1.6.36=ha441bb4_0 - - matplotlib=3.0.0=py35h54f8f79_0 - - mkl=2018.0.3=1 - - mkl_fft=1.0.6=py35hb8a8100_0 - - mkl_random=1.0.1=py35h5d10147_1 - - ncurses=6.1=h0a44026_1 - - numpy=1.15.2=py35h6a91979_0 - - numpy-base=1.15.2=py35h8a80b8c_0 - - openssl=1.0.2p=h1de35cc_0 - - pip=10.0.1=py35_0 - - pyface=6.0.0=py35_0 - - pygments=2.2.0=py35h392a662_0 - - pyparsing=2.2.1=py35_0 - - pyqt=4.11.4=py35_4 - - python=3.5.6=hc167b69_0 - - python-dateutil=2.7.3=py35_0 - - pytz=2018.5=py35_0 - - qt=4.8.7=4 - - readline=7.0=h1de35cc_5 - - setuptools=40.2.0=py35_0 - - sip=4.18=py35_0 - - six=1.11.0=py35_1 - - sqlite=3.26.0=ha441bb4_0 - - tk=8.6.8=ha441bb4_0 - - tornado=5.1.1=py35h1de35cc_0 - - traits=4.6.0=py35h1de35cc_3 - - traitsui=6.0.0=py35_1 - - wheel=0.31.1=py35_0 - - xz=5.2.4=h1de35cc_4 - - zlib=1.2.11=h1de35cc_3 -prefix: /Users/bgriffen/anaconda3/envs/Python3GUITemplate - diff --git a/Python3GUITemplate_ubuntu.yml b/Python3GUITemplate_ubuntu.yml deleted file mode 100644 index 10eae45..0000000 --- a/Python3GUITemplate_ubuntu.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Python3GUITemplate -channels: - - defaults -dependencies: - - backcall=0.1.0=py35_0 - - blas=1.0=mkl - - ca-certificates=2018.03.07=0 - - cairo=1.12.18=6 - - certifi=2018.8.24=py35_1 - - cycler=0.10.0=py35hc4d5149_0 - - decorator=4.3.0=py35_0 - - fontconfig=2.11.1=6 - - freetype=2.5.5=2 - - fribidi=1.0.5=h7b6447c_0 - - glib=2.56.2=hd408876_0 - - graphite2=1.3.13=h23475e2_0 - - harfbuzz=0.9.39=1 - - icu=58.2=h9c2bf20_1 - - intel-openmp=2019.1=144 - - ipython=6.5.0=py35_0 - - ipython_genutils=0.2.0=py35hc9e07d0_0 - - jedi=0.12.1=py35_0 - - libedit=3.1.20170329=h6b74fdf_2 - - libffi=3.2.1=hd88cf55_4 - - libgcc-ng=8.2.0=hdf63c60_1 - - libgfortran-ng=7.3.0=hdf63c60_0 - - libpng=1.6.36=hbc83047_0 - - libstdcxx-ng=8.2.0=hdf63c60_1 - - libuuid=1.0.3=h1bed415_2 - - libxcb=1.13=h1bed415_1 - - libxml2=2.9.8=h26e45fe_1 - - matplotlib=1.5.1=np111py35_0 - - mkl=2019.1=144 - - ncurses=6.1=he6710b0_1 - - numpy=1.11.3=py35h3dfced4_4 - - openssl=1.0.2p=h14c3975_0 - - pango=1.39.0=0 - - parso=0.3.1=py35_0 - - pcre=8.42=h439df22_0 - - pexpect=4.6.0=py35_0 - - pickleshare=0.7.4=py35hd57304d_0 - - pip=10.0.1=py35_0 - - pixman=0.32.6=0 - - prompt_toolkit=1.0.15=py35hc09de7a_0 - - ptyprocess=0.6.0=py35_0 - - pyface=6.0.0=py35_0 - - pygments=2.2.0=py35h0f41973_0 - - pyparsing=2.2.1=py35_0 - - pyqt=4.11.4=py35_4 - - python=3.5.6=hc3d631a_0 - - python-dateutil=2.7.3=py35_0 - - pytz=2018.5=py35_0 - - qt=4.8.7=4 - - readline=7.0=h7b6447c_5 - - setuptools=40.2.0=py35_0 - - simplegeneric=0.8.1=py35_2 - - sip=4.18=py35_0 - - six=1.11.0=py35_1 - - sqlite=3.26.0=h7b6447c_0 - - tk=8.6.8=hbc83047_0 - - traitlets=4.3.2=py35ha522a97_0 - - traits=4.6.0=py35h7b6447c_3 - - traitsui=6.0.0=py35_1 - - wcwidth=0.1.7=py35hcd08066_0 - - wheel=0.31.1=py35_0 - - xz=5.2.4=h14c3975_4 - - zlib=1.2.11=h7b6447c_3 -prefix: /home/bgriffen/anaconda3/envs/Python3GUITemplate - diff --git a/PythonGUI_mac.png b/PythonGUI_mac.png deleted file mode 100644 index f5b0def..0000000 Binary files a/PythonGUI_mac.png and /dev/null differ diff --git a/PythonGUI_ubuntu.png b/PythonGUI_ubuntu.png deleted file mode 100644 index e1e94fb..0000000 Binary files a/PythonGUI_ubuntu.png and /dev/null differ diff --git a/README.md b/README.md index 2dc8559..ab93d5d 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,169 @@ -Python3 GUI Template -================= +
-![View Upon Launch.](./PythonGUI_mac.png) +# Python3 GUI Template -Python2 [is dead](https://pythonclock.org/). Long live Python3. +A minimal, working starting point for scientific desktop apps built with Traits, TraitsUI, Qt5 and matplotlib. -This template is an improved and upgrade template made from the pieces of my [Python2Template](https://github.com/bgriffen/PythonGUITemplate). I've removed MayAvi and reduced it to the simplest components of Traits/TraitsUI+Matplotlib. The key difference here is that the backend is QT5 which leans heavily on the code found [here](https://gist.github.com/pierre-haessig/9838326). +[![CI](https://github.com/bgriffen/Python3GUITemplate/actions/workflows/ci.yml/badge.svg)](https://github.com/bgriffen/Python3GUITemplate/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.9%20%7C%203.11%20%7C%203.12-blue)](https://www.python.org/) +[![Qt](https://img.shields.io/badge/Qt-5-41cd52)](https://www.qt.io/) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -## Requirements +
-I've stripped back the requirements and wrapped it all up in a conda environment file as the key to getting this working at the relevant version dependencies. There are two options to get you going, either: +![The application on launch](.github/assets/screenshot.png) -``` +Python2 [is dead](https://pythonclock.org/). Long live Python3. + +This template is an upgrade of my [Python2 template](https://github.com/bgriffen/PythonGUITemplate), +stripped back to the simplest useful combination of Traits/TraitsUI and matplotlib — no Mayavi. The +figure embedding leans on [Pierre Haessig's gist](https://gist.github.com/pierre-haessig/9838326), +ported here to Qt5. + +Copy the repository, rename it, and start replacing the example tab with your own. + +## Features + +- **An embedded matplotlib canvas** with the standard navigation toolbar (pan, zoom, save), wired + into TraitsUI as a reusable editor. +- **A split layout** — the shared figure on the left, a tabbed panel of controls on the right — that + scales to as many tabs as you need. +- **Live plot styling**: marker shape, size and colour controls that act on the plotted data + immediately. +- **A worked example of the common widgets** — sliders, checkboxes, dropdowns, text fields, a + directory picker, buttons, read-only outputs, and a group that enables itself conditionally. +- **Headless tests and CI**, so you can refactor the template without wondering what you broke. + +## Prerequisites + +- Python 3.9 or newer +- A Qt binding. `requirements.txt` installs PyQt5; PySide2 and PySide6 also work, as pyface uses + whichever it finds. + +On a bare Linux machine you may also need Qt's system libraries — see the +[CI workflow](.github/workflows/ci.yml) for the exact package list. + +## Installation + +With pip: + +```bash +git clone https://github.com/bgriffen/Python3GUITemplate.git +cd Python3GUITemplate +python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt ``` -Verified on Ubuntu (16.04 verified) and Mac OSX (10.13.6 verified): +Or with conda: -Be sure to update your conda should you run into any issues: +```bash +conda env create -f environment.yml +conda activate Python3GUITemplate ``` -conda update conda + +## Usage + +```bash +python main.py ``` -## Getting Started +Drag the **gradient** and **y-intercept** sliders to redraw the line, then change the **Marker**, +**Size** and **Colour** controls to restyle it. **Button 1** rolls a random number, which disables +the *Boolean Option* group once it lands above 0.5 — an example of `enabled_when`. -To run this program, you simply load your environment and run. +To check your environment in isolation before debugging the full app: +```bash +python standalonematplotlib.py ``` -source activate Python3GUITemplate -python main.py + +That opens a single figure with a rectangle selector attached; drag a box over the curve and the +selection coordinates are printed to stdout. + +## Project structure + +| File | Purpose | +| --- | --- | +| `main.py` | `ApplicationMain` — the window, the shared figure, and the marker controls. Start here. | +| `firstcalc.py` | `FirstCalc` — the example tab. Copy this to add your own. | +| `CommonMPL.py` | `MPLFigureEditor`, the TraitsUI editor that embeds a matplotlib figure in Qt. | +| `standalonematplotlib.py` | The editor on its own, for smoke-testing an environment. | +| `tests/` | Headless regression tests. | + +### Adding a tab + +Write a `HasTraits` class with a `view`, taking the main window as its first argument — say in a new +`secondcalc.py`: + +```python +class SecondCalc(HasTraits): + amplitude = Range(0.0, 5.0, 1.0) + view = View(Item("amplitude")) + + def __init__(self, main, **kwargs): + super().__init__(**kwargs) + self.main = main + + def _amplitude_changed(self): + x = np.linspace(0, 10, 200) + self.main.plot_xy(x, self.amplitude * np.sin(x)) +``` + +Then import it in `main.py` and register it on `ApplicationMain`: + +```python +secondcalc = Instance(SecondCalc) + +def _secondcalc_default(self): + return SecondCalc(self) + +right_panel = Tabbed( + Item("firstcalc", style="custom", label="First Tab", show_label=False), + Item("secondcalc", style="custom", label="Second Tab", show_label=False), +) +``` + +Route drawing through `ApplicationMain.plot_xy()` rather than touching the figure directly. It +reuses the axes and keeps the marker controls pointing at the current line; clearing the figure +yourself throws away the axis labels and silently breaks them. + +## Testing + +```bash +pip install pytest +pytest ``` -There is very comprehensive documentation on using Traits [here](http://code.enthought.com/projects/traits/documentation.php) and a lot of [tutorials](http://docs.enthought.com/traitsui/tutorials/index.html). I have some additional information relating to the Traits/TraitsUI functionality found [here](http://brendangriffen.com/creating-a-GUI-in-Python/). Note: that post is based on the old Python2 version. +The suite runs without a display — `conftest.py` selects Qt's `offscreen` platform plugin — so it +works over ssh, in containers and in CI. + +```bash +ruff check . # lint, matching the CI job +``` + +## Notes on Traits + +Traits calls a method named `__changed` whenever that trait's value changes, and +`__fired` when a `Button` is pressed. That naming is the whole wiring mechanism — there is no +explicit connect step. Two consequences worth knowing: + +- A `Range` whose default sits outside its own bounds will be clamped by the editor as the UI is + built, firing a change handler before the window is ever shown. +- The methods are bound when the class is created, so patching one afterwards has no effect. + +There is comprehensive documentation for +[Traits](http://code.enthought.com/projects/traits/documentation.php) and a set of +[TraitsUI tutorials](http://docs.enthought.com/traitsui/tutorials/index.html). I wrote up +some additional background [on my site](http://brendangriffen.com/creating-a-GUI-in-Python/), though +that post covers the older Python2 version. + +## Acknowledgments + +- [Pierre Haessig](https://gist.github.com/pierre-haessig/9838326) for the Qt matplotlib editor. +- Gael Varoquaux's + [TraitsUI scientific application tutorial](http://docs.enthought.com/traitsui/tutorials/traits_ui_scientific_app.html), + and Didrik Pinte for the original Qt version. + +## License -There is an additional script `standalonematplotlib.py` which just demonstrates the matplotlib functionality. +[MIT](LICENSE) diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..b548087 --- /dev/null +++ b/environment.yml @@ -0,0 +1,22 @@ +# Conda environment for the template. +# +# conda env create -f environment.yml +# conda activate Python3GUITemplate +# python main.py +# +# Replaces the previous per-platform lock files, which pinned Python 3.5, +# Qt 4 and TraitsUI 6 against exact build hashes that no longer resolve. +# One loosely-pinned file works on Linux, macOS and Windows alike. +name: Python3GUITemplate +channels: + - conda-forge +dependencies: + - python>=3.9 + - matplotlib-base>=3.5 + - numpy>=1.20 + - pyface>=8.0 + - pyqt>=5.15 + - traits>=6.2 + - traitsui>=8.0 + # Development only; drop if you just want to run the app. + - pytest>=7.0 diff --git a/firstcalc.py b/firstcalc.py index e92e0a6..dc57136 100644 --- a/firstcalc.py +++ b/firstcalc.py @@ -1,94 +1,106 @@ -from CommonMPL import * +#!/usr/bin/env python3 +"""Example tab showing the common TraitsUI widgets wired to a live plot. + +Each trait below maps to a control in :attr:`FirstCalc.view`; the +``__changed`` and ``__fired`` methods are the notifications Traits +calls automatically when a value changes or a button is pressed. +""" + +import os +import random + +import numpy as np +from traits.api import ( + Bool, + Button, + Directory, + Enum, + Float, + HasTraits, + Int, + Range, + Str, +) +from traitsui.api import Group, Item, View + class FirstCalc(HasTraits): + """Demonstration panel driving the shared figure owned by the main window.""" # Add Traits objects option = Bool(True) - rangex = Range(1,10,5) + rangex = Range(1, 10, 5) yval = Float() - listoptions = Enum(['Option 1', 'Option 2','Option 2']) + listoptions = Enum("Option 1", "Option 2", "Option 3") stringopt = Str("Default string which can also be empty.") stringoptread = Str("Default string which can also be empty [read-only version].") - masterpath = Directory(os.getcwd()) + masterpath = Directory() button1 = Button("Button 1 Name") floatval = Float() changedcount = Int() plotbutton = Button("Plot Me!") - yintcept = Range(0.0,5.,10.) - gradient = Range(0.0,5.0,10.) + yintcept = Range(0.0, 5.0, 0.0) + gradient = Range(0.0, 5.0, 1.0) - #Construct the view + # Construct the view view = View( - Item(name='rangex',label='X-Value'), - Item(name='yval',style='readonly',label='1/x',format_str='%.2e'), - Item(name='listoptions',label='list of options'), - Item(name='stringopt'), - Item(name='stringoptread',style='readonly'), - Item(name='masterpath',label='Directory'), - Group(Item(name='button1',show_label=False)), - Group(Item(name='option',label='Boolean Option') - ,enabled_when='floatval < 0.5',label='Enabled Area Upon Random Value < 0.5',show_border=True - ), - Item(name='changedcount',style='readonly',label='Attempts'), - Item(name='floatval',label='random generated'), - Group(Item(name='gradient',label='gradient'), - Item(name='yintcept',label='y-intercept'), - Item(name='plotbutton',show_label=False),show_border=True,label='Plotting Area') - ) + Item(name="rangex", label="X-Value"), + Item(name="yval", style="readonly", label="1/x", format_str="%.2e"), + Item(name="listoptions", label="list of options"), + Item(name="stringopt"), + Item(name="stringoptread", style="readonly"), + Item(name="masterpath", label="Directory"), + Group(Item(name="button1", show_label=False)), + Group( + Item(name="option", label="Boolean Option"), + enabled_when="floatval < 0.5", + label="Enabled Area Upon Random Value < 0.5", + show_border=True, + ), + Item(name="changedcount", style="readonly", label="Attempts"), + Item(name="floatval", label="random generated"), + Group( + Item(name="gradient", label="gradient"), + Item(name="yintcept", label="y-intercept"), + Item(name="plotbutton", show_label=False), + show_border=True, + label="Plotting Area", + ), + ) + + def __init__(self, main, **kwargs): + super().__init__(**kwargs) + self.main = main + self.yval = 1.0 / self.rangex + def _masterpath_default(self): + return os.getcwd() + + def _line(self): + """Return the (x, y) of the straight line described by the sliders.""" + x = np.arange(10) + return x, self.gradient * x + self.yintcept + + def _replot(self): + self.main.plot_xy(*self._line()) + + # Both sliders describe the same line, so they share one handler. def _gradient_changed(self): - y = self.gradient * np.array(range(10)) + self.yintcept - - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - ax = self.main.display.axes[0] - ax.plot(np.array(range(10)),y,color=self.main.markercolor, - marker=self.main.markerstyle, - markersize=self.main.markersize, - markeredgewidth=0.0, - linestyle='-') - self.main.display.canvas.draw() + self._replot() def _yintcept_changed(self): - y = self.gradient * np.array(range(10)) + self.yintcept - - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - ax = self.main.display.axes[0] - ax.plot(np.array(range(10)),y,color=self.main.markercolor, - marker=self.main.markerstyle, - markersize=self.main.markersize, - markeredgewidth=0.0, - linestyle='-') - self.main.display.canvas.draw() - - def _rangeplotx_changed(self): - figure.clear() - y - ax = figure.add_subplot(111) - ax = self.main.display.axes[0] - ax.plot(xtmp,ytmp,color=self.main.markercolor, - marker=self.main.markerstyle, - markersize=self.main.markersize, - markeredgewidth=0.0, - linestyle='None') - self.main.display.canvas.draw() + self._replot() + + def _plotbutton_fired(self): + self._replot() def _rangex_changed(self): - self.yval = 1./self.rangex + self.yval = 1.0 / self.rangex def _button1_fired(self): self.changedcount += 1 self.floatval = random.random() def _floatval_changed(self): - self.stringopt = "Hey, you changed the integer value!" - - def __init__(self, main, **kwargs): - HasTraits.__init__(self) - self.main = main - - + self.stringopt = "Hey, you changed the value!" diff --git a/main.py b/main.py index 427b2d0..ad3ac2f 100644 --- a/main.py +++ b/main.py @@ -1,76 +1,126 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- +#!/usr/bin/env python3 # Brendan Griffen - January 2019 +"""Entry point for the template application. +Layout is a horizontal split: a matplotlib canvas plus its marker controls on +the left, and a tabbed panel of calculation widgets on the right. Add a tab by +writing another ``HasTraits`` class alongside :class:`~firstcalc.FirstCalc` and +adding it to ``right_panel``. +""" + +from matplotlib.figure import Figure +from traits.api import Any, Enum, HasTraits, Instance, Range +from traitsui.api import Group, HGroup, HSplit, Item, Tabbed, View + +from CommonMPL import MPLFigureEditor from firstcalc import FirstCalc -from CommonMPL import * + class ApplicationMain(HasTraits): + """Top-level window owning the shared figure and its display settings.""" firstcalc = Instance(FirstCalc) display = Instance(Figure) - markerstyle = Enum(['+',',','*','s','p','d','o']) - markersize = Range(0,10,2) - - left_panel = Group(Item('display', editor=MPLFigureEditor(),show_label=False, resizable=True), - HGroup(Item(name='markerstyle', label="Marker"), - Item(name='markersize', label="Size"))) - - right_panel = Tabbed(Item('firstcalc', style='custom', label='First Tab',show_label=False)) - - view = View(HSplit(left_panel, - right_panel), - width = 1280, - height = 550, - resizable=True, - title="My First Python3 GUI Interface" - ) + + markercolor = Enum("blue", "red", "green", "black", "orange") + markerstyle = Enum("o", "+", ",", "*", "s", "p", "d") + markersize = Range(0, 10, 2) + + #: The matplotlib Line2D currently on the axes, or None before first plot. + display_points = Any(None) + + left_panel = Group( + Item("display", editor=MPLFigureEditor(), show_label=False, resizable=True), + HGroup( + Item(name="markerstyle", label="Marker"), + Item(name="markersize", label="Size"), + Item(name="markercolor", label="Colour"), + ), + ) + + right_panel = Tabbed( + Item("firstcalc", style="custom", label="First Tab", show_label=False) + ) + + view = View( + HSplit(left_panel, right_panel), + width=1280, + height=550, + resizable=True, + title="My First Python3 GUI Interface", + ) def _display_default(self): - """Initialises the display.""" + """Initialise the display.""" figure = Figure() - ax = figure.add_subplot(111) - ax = figure.axes[0] - ax.set_xlabel('X') - ax.set_ylabel('Y') - ax.set_xlim(0,1) - ax.set_ylim(0,1) - + axes = figure.add_subplot(111) + axes.set_xlabel("X") + axes.set_ylabel("Y") + axes.set_xlim(0, 1) + axes.set_ylim(0, 1) + # Set matplotlib canvas colour to be white - rect = figure.patch - rect.set_facecolor('w') + figure.patch.set_facecolor("w") return figure def _firstcalc_default(self): return FirstCalc(self) - def _secondcalc_default(self): - return SecondCalc(self) + def plot_xy(self, x, y): + """Draw ``x``/``y`` using the current marker settings. + + Reuses the existing line and axes rather than clearing the figure, so + the axis labels survive a replot and the marker controls keep a live + handle on what is drawn. + """ + axes = self.display.axes[0] + + if self.display_points is None: + (self.display_points,) = axes.plot( + x, + y, + color=self.markercolor, + marker=self.markerstyle, + markersize=self.markersize, + markeredgewidth=0.0, + linestyle="-", + ) + else: + self.display_points.set_data(x, y) + + # _display_default pins 0..1 as a placeholder for the empty plot, which + # switches autoscaling off; turn it back on now there is real data. + axes.relim() + axes.autoscale(enable=True) + self.redraw() + + def redraw(self): + """Request a canvas repaint, tolerating a figure with no canvas yet.""" + canvas = self.display.canvas + if canvas is not None: + canvas.draw_idle() def _markercolor_changed(self): - ax = self.display.axes[0] - if hasattr(self, 'display_points'): + if self.display_points is not None: self.display_points.set_color(self.markercolor) self.display_points.set_markeredgecolor(self.markercolor) - wx.CallAfter(self.display.canvas.draw) + self.redraw() def _markerstyle_changed(self): - ax = self.display.axes[0] - if hasattr(self, 'display_points'): + if self.display_points is not None: self.display_points.set_marker(self.markerstyle) - wx.CallAfter(self.display.canvas.draw) + self.redraw() def _markersize_changed(self): - ax = self.display.axes[0] - if hasattr(self, 'display_points'): + if self.display_points is not None: self.display_points.set_markersize(self.markersize) - wx.CallAfter(self.display.canvas.draw) + self.redraw() + + +def run(): + """Launch the application.""" + ApplicationMain().configure_traits() - def __init__(self, **kwargs): - self.markercolor = 'blue' - self.markersize = 2 - self.markerstyle = 'o' -if __name__ == '__main__': - app = ApplicationMain() - app.configure_traits() +if __name__ == "__main__": + run() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6592392 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +# Tool configuration only. +# +# This repository is a template to copy and edit in place, not a library to +# install, so it deliberately declares no build backend or distribution +# metadata -- publishing top-level modules named "main" and "CommonMPL" into +# site-packages would collide with anything else doing the same. + +[tool.pytest.ini_options] +testpaths = ["tests"] +# The modules under test sit at the repository root and are imported as +# top-level scripts, matching how the application runs. +pythonpath = ["."] +addopts = "-ra" + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/requirements.txt b/requirements.txt index 0301ae2..eb04a9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,13 @@ -matplotlib==3.7.0 -numpy==1.24.3 -pyface==8.0.0 -traits==6.4.1 -traitsui==8.0.0 +# Runtime dependencies. Lower bounds reflect what the code actually needs: +# matplotlib >= 3.5 -- backend_qtagg (backend_qt4agg was removed in 3.5) +# traitsui >= 8.0 -- traitsui.qt (traitsui.qt4 was removed in 8.0) +# +# PyQt5 is required: TraitsUI and matplotlib both need a Qt binding, and +# neither declares one. Swap it for PySide2/PySide6 if you prefer -- pyface +# resolves whichever is installed. +matplotlib>=3.5 +numpy>=1.20 +pyface>=8.0 +PyQt5>=5.15 +traits>=6.2 +traitsui>=8.0 diff --git a/standalonematplotlib.py b/standalonematplotlib.py index bbe91e4..f451477 100644 --- a/standalonematplotlib.py +++ b/standalonematplotlib.py @@ -1,87 +1,52 @@ -from traits.api import HasTraits, Instance -from traitsui.api import View, Item, Handler -from traitsui.editor import Editor -from traitsui.basic_editor_factory import BasicEditorFactory - -from matplotlib.figure import Figure -from matplotlib.backends.backend_qt5agg import ( - FigureCanvasQTAgg as FigureCanvas, - NavigationToolbar2QT as NavigationToolbar, -) - -from pyface.qt import QtGui - -from numpy import sin, cos, linspace, pi -from matplotlib.widgets import RectangleSelector - - -class _MPLFigureEditor(Editor): - scrollable = True - - def init(self, parent): - self.control = self._create_canvas(parent) - self.set_tooltip() +#!/usr/bin/env python3 +"""Minimal demo of the matplotlib editor on its own, without the full app. - def update_editor(self): - pass +Run this to check your environment renders a figure and receives mouse events +before debugging anything in ``main.py``: - def set_size_policy(self, direction, resizable, springy, stretch): - pass - # Rest of your class code. + python standalonematplotlib.py - def _create_canvas(self, parent): - """ Create the MPL canvas. """ - # matplotlib commands to create a canvas - frame = QtGui.QWidget() - mpl_canvas = FigureCanvas(self.value) - mpl_canvas.setParent(frame) - mpl_toolbar = NavigationToolbar(mpl_canvas, frame) - - vbox = QtGui.QVBoxLayout() - vbox.addWidget(mpl_canvas) - vbox.addWidget(mpl_toolbar) - frame.setLayout(vbox) - - return frame - - -class MPLFigureEditor(BasicEditorFactory): - klass = _MPLFigureEditor +Drag a box over the curve; the selection coordinates are printed to stdout. +""" +from matplotlib.figure import Figure +from matplotlib.widgets import RectangleSelector +from numpy import cos, linspace, pi, sin +from traits.api import HasTraits, Instance +from traitsui.api import Item, View -class MPLInitHandler(Handler): - """Handler calls mpl_setup() to initialize mpl events""" +from CommonMPL import MPLFigureEditor, MPLInitHandler - def init(self, info): - """This method gets called after the controls have all been - created but before they are displayed. - """ - info.object.mpl_setup() - return True +class RectangleSelectorDemo(HasTraits): + """A single figure with a rectangle selector attached to its axes.""" -class Test(HasTraits): figure = Instance(Figure, ()) view = View( - Item('figure', editor=MPLFigureEditor(), show_label=False), + Item("figure", editor=MPLFigureEditor(), show_label=False), handler=MPLInitHandler, - resizable=True + resizable=True, + width=640, + height=480, + title="Matplotlib in TraitsUI", ) - def __init__(self): - super(Test, self).__init__() + def __init__(self, **kwargs): + super().__init__(**kwargs) self.axes = self.figure.add_subplot(111) t = linspace(0, 2 * pi, 200) self.axes.plot(sin(t) * (1 + 0.5 * cos(11 * t)), cos(t) * (1 + 0.5 * cos(11 * t))) def mpl_setup(self): + """Called by MPLInitHandler once the canvas exists.""" + def onselect(eclick, erelease): print(f"Start position: {eclick}, End position: {erelease}") + # Held on self so the selector is not garbage collected. self.rs = RectangleSelector(self.axes, onselect, useblit=True) - if __name__ == "__main__": - Test().configure_traits() + RectangleSelectorDemo().configure_traits() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..06353e9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""Shared fixtures. + +Qt needs a platform plugin and a QApplication before any widget exists. Both +are set up here so the suite runs on a headless machine (CI, ssh, container) +with no display attached. +""" + +import os + +# Must be set before any Qt binding is imported. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("ETS_TOOLKIT", "qt") + +import pytest # noqa: E402 + + +@pytest.fixture(scope="session") +def qt_app(): + """A process-wide QApplication; Qt allows only one.""" + from pyface.qt import QtGui + + app = QtGui.QApplication.instance() or QtGui.QApplication([]) + yield app + + +@pytest.fixture +def main_window(): + """An ApplicationMain with no UI built, for testing plotting logic.""" + from main import ApplicationMain + + return ApplicationMain() + + +@pytest.fixture +def live_ui(qt_app, main_window): + """An ApplicationMain with its real widget tree constructed and disposed. + + Building the UI is what surfaces bugs that only fire when TraitsUI editors + initialise, so some regressions can only be caught this way. + """ + ui = main_window.edit_traits() + yield main_window + ui.dispose() diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..7730e28 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,91 @@ +"""Behaviour of the main window's figure and marker controls.""" + +from main import ApplicationMain + + +def test_display_default_labels_the_axes(main_window): + axes = main_window.display.axes[0] + assert (axes.get_xlabel(), axes.get_ylabel()) == ("X", "Y") + assert axes.get_xlim() == (0.0, 1.0) + + +def test_no_line_before_first_plot(main_window): + assert main_window.display_points is None + + +def test_marker_handlers_are_safe_before_any_plot(main_window): + """These used to guard on an attribute nothing ever set.""" + main_window.markerstyle = "s" + main_window.markersize = 6 + main_window.markercolor = "red" + assert main_window.display_points is None + + +def test_plot_xy_creates_a_line(main_window): + main_window.plot_xy([0, 1, 2], [0, 2, 4]) + line = main_window.display_points + assert line is not None + assert list(line.get_ydata()) == [0, 2, 4] + + +def test_plot_xy_reuses_the_same_line_and_axes(main_window): + main_window.plot_xy([0, 1], [0, 1]) + line, axes = main_window.display_points, main_window.display.axes[0] + + main_window.plot_xy([0, 1], [5, 6]) + + assert main_window.display_points is line + assert main_window.display.axes[0] is axes + assert list(line.get_ydata()) == [5, 6] + + +def test_replotting_keeps_the_axis_labels(main_window): + """The old handlers called figure.clear(), which threw the labels away.""" + main_window.plot_xy([0, 1, 2], [0, 2, 4]) + axes = main_window.display.axes[0] + assert (axes.get_xlabel(), axes.get_ylabel()) == ("X", "Y") + + +def test_plot_xy_rescales_past_the_placeholder_limits(main_window): + """_display_default's 0..1 limits switch autoscaling off; plot_xy re-enables it.""" + main_window.plot_xy([0, 1, 2], [0, 20, 40]) + axes = main_window.display.axes[0] + assert axes.get_xlim()[1] >= 2 + assert axes.get_ylim()[1] >= 40 + + +def test_marker_controls_reach_the_plotted_line(main_window): + main_window.plot_xy([0, 1, 2], [0, 2, 4]) + + main_window.markerstyle = "d" + main_window.markersize = 8 + main_window.markercolor = "green" + + line = main_window.display_points + assert line.get_marker() == "d" + assert line.get_markersize() == 8 + assert line.get_color() == "green" + assert line.get_markeredgecolor() == "green" + + +def test_defaults(): + window = ApplicationMain() + assert window.markercolor == "blue" + assert window.markerstyle == "o" + assert window.markersize == 2 + + +def test_kwargs_reach_hastraits(): + """The old __init__ swallowed **kwargs and skipped HasTraits.__init__.""" + assert ApplicationMain(markercolor="red").markercolor == "red" + + +def test_no_reference_to_a_missing_secondcalc(): + assert not hasattr(ApplicationMain, "_secondcalc_default") + + +def test_full_ui_builds_without_destroying_the_axes(live_ui): + """Regression: building the UI used to clear the figure via a clamped slider.""" + axes = live_ui.display.axes[0] + assert (axes.get_xlabel(), axes.get_ylabel()) == ("X", "Y") + assert len(live_ui.display.axes) == 1 diff --git a/tests/test_commonmpl.py b/tests/test_commonmpl.py new file mode 100644 index 0000000..e4697cf --- /dev/null +++ b/tests/test_commonmpl.py @@ -0,0 +1,64 @@ +"""Guards on the Qt5 port of the shared matplotlib editor.""" + +import pathlib + +import pytest + +import CommonMPL + +SOURCES = sorted(pathlib.Path(__file__).resolve().parent.parent.glob("*.py")) + +# Names that only exist in the Qt4 stack. Their reappearance means the app has +# regressed to imports that raise on matplotlib >= 3.5 / TraitsUI >= 8. +QT4_MARKERS = ["Qt4Agg", "backend_qt4agg", "traitsui.qt4", "wx.CallAfter"] + + +def test_exports_only_the_editor_api(): + assert CommonMPL.__all__ == ["MPLFigureEditor", "MPLInitHandler"] + + +def test_editor_factory_is_wired_to_the_qt_editor(): + assert CommonMPL.MPLFigureEditor().klass is CommonMPL._MPLFigureEditor + + +def test_qt_editor_base_supplies_set_size_policy(): + """Subclassing the Qt Editor is what makes the figure resize correctly.""" + assert hasattr(CommonMPL._MPLFigureEditor, "set_size_policy") + + +@pytest.mark.parametrize("source", SOURCES, ids=lambda p: p.name) +@pytest.mark.parametrize("marker", QT4_MARKERS) +def test_no_qt4_or_wx_references_remain(source, marker): + assert marker not in source.read_text() + + +def test_matplotlib_and_pyface_agree_on_the_qt_binding(): + """Two different bindings in one process crash on the first shared widget.""" + from matplotlib.backends.qt_compat import QT_API + from pyface.qt import qt_api + + assert QT_API.lower() == qt_api.lower() + + +def test_canvas_and_toolbar_are_built(qt_app): + """The editor produces a frame holding both a canvas and a toolbar.""" + from matplotlib.backends.backend_qtagg import ( + FigureCanvasQTAgg, + NavigationToolbar2QT, + ) + from matplotlib.figure import Figure + from traits.api import HasTraits, Instance + from traitsui.api import Item, View + + class Demo(HasTraits): + figure = Instance(Figure, ()) + view = View(Item("figure", editor=CommonMPL.MPLFigureEditor())) + + demo = Demo() + ui = demo.edit_traits() + try: + frame = ui.get_editors("figure")[0].control + assert frame.findChild(FigureCanvasQTAgg) is not None + assert frame.findChild(NavigationToolbar2QT) is not None + finally: + ui.dispose() diff --git a/tests/test_firstcalc.py b/tests/test_firstcalc.py new file mode 100644 index 0000000..251dd54 --- /dev/null +++ b/tests/test_firstcalc.py @@ -0,0 +1,71 @@ +"""Behaviour of the example calculation tab.""" + +import pytest +from traits.api import Range + +from firstcalc import FirstCalc + + +@pytest.fixture +def calc(main_window): + return main_window.firstcalc + + +@pytest.mark.parametrize("name", ["rangex", "yintcept", "gradient"]) +def test_range_defaults_lie_inside_their_bounds(name): + """gradient and yintcept were Range(0.0, 5.0, 10.0) -- default out of range. + + Traits keeps the bad value at init and the editor clamps it later, firing a + change handler mid-startup. + """ + trait = FirstCalc.class_traits()[name].handler + assert isinstance(trait, Range) + assert trait._low <= trait.default_value <= trait._high + + +def test_listoptions_has_no_duplicates(): + """'Option 2' was listed twice, so the menu only ever offered two choices.""" + values = FirstCalc.class_traits()["listoptions"].handler.values + assert len(values) == len(set(values)) == 3 + + +def test_yval_is_initialised(calc): + assert calc.yval == pytest.approx(1.0 / calc.rangex) + + +def test_rangex_updates_yval(calc): + calc.rangex = 4 + assert calc.yval == pytest.approx(0.25) + + +def test_button1_counts_and_randomises(calc): + calc._button1_fired() + assert calc.changedcount == 1 + assert 0.0 <= calc.floatval <= 1.0 + assert calc.stringopt == "Hey, you changed the value!" + + +def test_masterpath_defaults_to_cwd(tmp_path, monkeypatch, main_window): + """Taken at instantiation, not frozen at import time.""" + monkeypatch.chdir(tmp_path) + assert FirstCalc(main_window).masterpath == str(tmp_path) + + +def test_sliders_plot_through_the_main_window(calc, main_window): + calc.gradient = 2.0 + calc.yintcept = 1.0 + + line = main_window.display_points + assert line is not None + # y = 2x + 1 over x = 0..9 + assert list(line.get_ydata()) == [2 * x + 1 for x in range(10)] + + +def test_plot_button_replots(calc, main_window): + """'Plot Me!' previously had no _plotbutton_fired handler at all.""" + calc._plotbutton_fired() + assert main_window.display_points is not None + + +def test_broken_rangeplotx_handler_is_gone(): + assert not hasattr(FirstCalc, "_rangeplotx_changed")