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
-=================
+
-
+# 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).
+[](https://github.com/bgriffen/Python3GUITemplate/actions/workflows/ci.yml)
+[](https://www.python.org/)
+[](https://www.qt.io/)
+[](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:
+
-```
+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")