diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..fe494d6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,50 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.9", "3.11", "3.12"]
+
+ env:
+ # TraitsUI needs a toolkit at import time; run Qt against the offscreen
+ # platform plugin so no display server is required.
+ QT_QPA_PLATFORM: offscreen
+ ETS_TOOLKIT: qt
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install system libraries for Qt
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libegl1 libgl1 libxkbcommon-x11-0 libdbus-1-3 \
+ libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
+ libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xinerama0
+
+ - name: Install package
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e '.[qt,dev]'
+
+ - name: Lint
+ run: ruff check .
+
+ - name: Check formatting
+ run: ruff format --check .
+
+ - name: Run tests
+ run: pytest --cov=pyguitemplate --cov-report=term-missing
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d69e5b1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,31 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Distribution / packaging
+build/
+dist/
+*.egg-info/
+.eggs/
+
+# Unit test / coverage reports
+.pytest_cache/
+.coverage
+.coverage.*
+htmlcov/
+.tox/
+
+# Environments
+.venv/
+venv/
+env/
+
+# Editors / IDEs
+.idea/
+.vscode/
+*.swp
+
+# OS cruft
+.DS_Store
+Thumbs.db
diff --git a/Common.py b/Common.py
deleted file mode 100644
index 9a8eb43..0000000
--- a/Common.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from matplotlib.pyplot import Figure
-from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
-from matplotlib.backends.backend_wx import NavigationToolbar2Wx
-
-# if using Enthought
-#from enthought.traits.api import *
-#from enthought.traits.ui.api import View,UItem, Item, Group, Heading, Label, \
-# HSplit, Handler, CheckListEditor, EnumEditor, TableEditor, \
-# ListEditor, Tabbed, VGroup, HGroup, RangeEditor, Spring, spring
-#from enthought.traits.ui.menu import NoButtons
-##from enthought.traits.api import Any, Instance
-#from enthought.traits.ui.wx.editor import Editor
-#from enthought.traits.ui.wx.basic_editor_factory import BasicEditorFactory
-#from enthought.enable.api import ColorTrait
-
-# If installing traits/traitsui as standalone or using Conda
-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
-from traitsui.wx.editor import Editor
-from traitsui.wx.basic_editor_factory import BasicEditorFactory
-from traitsui.api import ColorTrait
-
-import numpy as np
-from matplotlib import *
-import os,sys,wx,platform,socket,random
-
-class _MPLFigureEditor(Editor):
-
- scrollable = True
-
- def init(self, parent):
- self.control = self._create_canvas(parent)
- self.set_tooltip()
-
- def update_editor(self):
- pass
-
- def _create_canvas(self, parent):
- """ Create the MPL canvas. """
- # The panel lets us add additional controls.
-
- panel = wx.Panel(parent, -1, style=wx.CLIP_CHILDREN)
- sizer = wx.BoxSizer(wx.VERTICAL)
- panel.SetSizer(sizer)
-
- # matplotlib commands to create a canvas
- mpl_control = FigureCanvas(panel, -1, self.value)
- sizer.Add(mpl_control, 1, wx.LEFT | wx.TOP | wx.GROW)
- toolbar = NavigationToolbar2Wx(mpl_control)
- sizer.Add(toolbar, 0, wx.EXPAND)
- self.value.canvas.SetMinSize((10,10))
-
- return panel
-
-class MPLFigureEditor(BasicEditorFactory):
-
- klass = _MPLFigureEditor
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..59e2fd8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2013 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/README.md b/README.md
index 9308b29..8c47282 100644
--- a/README.md
+++ b/README.md
@@ -1,53 +1,218 @@
-Python GUI Template
-=================
+
-A basic template for making a GUI in Python using Traits. It gives you access to a matplotlib canvas object and Mayavi 3D rendering object which can be dynamically updated by adjusting the objects on the right panel. Tabbed viewing is also enabled by default.
+# Python GUI Template
+
+A batteries-included starting point for scientific desktop GUIs in Python, built on Traits, TraitsUI, Matplotlib and Mayavi.
+
+[](https://github.com/bgriffen/PythonGUITemplate/actions/workflows/ci.yml)
+[](https://www.python.org/)
+[](LICENSE)
+[](https://github.com/astral-sh/ruff)
+
+
+
+Clone it, run it, then replace the example panels with your own. You get a
+resizable two-pane window with an embedded Matplotlib canvas (including the
+standard navigation toolbar), an optional Mayavi 3D scene, and tabbed control
+panels wired to the display — all of which usually take a frustrating afternoon
+to assemble from scratch.
+
+## Features
+
+- **Embedded Matplotlib canvas** with the full navigation toolbar, in a
+ resizable split pane.
+- **Runs under Qt or wx.** The Matplotlib editor picks the right canvas for
+ whichever ETS toolkit is active, so you are not locked into wxPython.
+- **Optional 3D.** Mayavi powers a 3D scene tab when installed; when it is not,
+ the tab degrades to an install hint and everything else keeps working.
+- **Live-updating controls.** Marker colour, style and size apply to the
+ current plot immediately; panel controls redraw as you drag them.
+- **Two worked example panels** covering the editors you will actually reach
+ for — ranges, enums, booleans, buttons, directory and file pickers,
+ `enabled_when`, read-only readouts and CSV export.
+- **Tested and linted**, with a headless pytest suite and CI.
## Requirements
-There are two options to get you going, either:
+- Python 3.9 or newer
+- One GUI toolkit: **Qt** (recommended) or **wx**
+- TraitsUI 7.2+ — older versions render black backgrounds and other oddities
+
+## Installation
+
+```bash
+git clone https://github.com/bgriffen/PythonGUITemplate.git
+cd PythonGUITemplate
+python -m venv .venv && source .venv/bin/activate
+```
+
+Then install with the toolkit you want. Pick **one**:
+
+```bash
+pip install -e '.[qt]' # Qt via PySide6 - recommended, installs cleanly everywhere
+pip install -e '.[wx]' # wxPython - the original backend
+```
+
+Qt is the easier of the two to install; wxPython frequently needs to build from
+source on Linux. If you install both, set `ETS_TOOLKIT=qt` (or `wx`) to say
+which one wins.
+
+### Optional: the 3D scene
+
+```bash
+pip install -e '.[qt,3d]'
+```
+
+This pulls in Mayavi and VTK — a large download and the most fragile part of
+the stack. It is entirely optional: without it the app runs normally and the
+Mayavi tab shows installation instructions instead.
+
+## Running
+
+```bash
+python main.py
+```
+
+or, equivalently:
+
+```bash
+python -m pyguitemplate
+pyguitemplate # console script, after installing
+```
+
+On macOS with the **wx** backend you may need `pythonw main.py` instead, so the
+process is treated as a proper windowed application. This is not necessary
+under Qt.
-* you will need Enthought's EPD distribution (32-bit, **not** 64-bit as it does not include Mayavi) which can be found [here](https://www.enthought.com/repo/epd/installers/). You will need an academic license to access it for free (simply requires a .edu email address). Go to the next step to install EPD if this is your choice (or if you are a beginner).
+## Project layout
-or,
+```
+pyguitemplate/
+├── app.py # ApplicationMain: the window, the split layout, shared plot state
+├── mpl_editor.py # Toolkit-agnostic TraitsUI editor embedding a Matplotlib figure
+├── scene.py # Mayavi 3D tab, with a fallback when Mayavi is missing
+├── colors.py # ColorTrait -> Matplotlib colour conversion
+└── panels/
+ ├── first_panel.py # Example: a tour of the common Traits editors
+ └── second_panel.py # Example: a small signal generator with CSV export
+tests/ # Headless pytest suite
+main.py # Launcher
+```
+
+## Adding your own panel
+
+Panels are plain `HasTraits` classes. They receive the `ApplicationMain`
+instance so they can drive the shared display, and they declare a `View` that
+lays out their controls.
+
+```python
+# pyguitemplate/panels/my_panel.py
+import numpy as np
+from traits.api import Any, Button, HasTraits, Range
+from traitsui.api import Item, View
+
+
+class MyPanel(HasTraits):
+ main = Any()
+
+ exponent = Range(1.0, 4.0, 2.0)
+ plotbutton = Button("Plot")
+
+ view = View(
+ Item("exponent", label="Exponent"),
+ Item("plotbutton", show_label=False),
+ )
+
+ def replot(self):
+ x = np.linspace(0.0, 5.0, 200)
+ self.main.plot(x, x**self.exponent)
+
+ # Traits calls this automatically whenever `exponent` changes.
+ def _exponent_changed(self):
+ self.replot()
-* if you don't use EPD, you will need to change the `Common.py` file to have the header not use EPD's distribution. Simply replace the following where appropriate (you will need [TraitsUI](https://github.com/enthought/traitsui) **v5.0 or above** however):
+ def _plotbutton_fired(self):
+ self.replot()
+
+ def __init__(self, main, **kwargs):
+ super().__init__(**kwargs)
+ self.main = main
+```
+
+Then register it in `app.py` — add an `Instance` trait, a `_my_panel_default`
+factory, and an `Item` in `right_panel`:
```python
-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
-from traitsui.wx.editor import Editor
-from traitsui.wx.basic_editor_factory import BasicEditorFactory
-from traitsui.api import ColorTrait
+my_panel = Instance(MyPanel)
+
+def _my_panel_default(self):
+ return MyPanel(self)
+
+right_panel = Tabbed(
+ ...,
+ Item("my_panel", style="custom", label="My Tab", show_label=False),
+)
```
-If you do not have TraitsUI **v5.0 or above**, then you will get black backgrounds and odd interface errors. This took me quite some time to solve so please heed my advice!
+Two conventions worth following:
+
+- **Plot through `main.plot(x, y)`** rather than touching the figure yourself.
+ It records the line it draws, which is what lets the marker controls restyle
+ it afterwards.
+- **Run colours through `to_mpl_color()`** if you read a `ColorTrait`.
+ TraitsUI stores a toolkit-native `QColor`/`wx.Colour`, and Matplotlib rejects
+ both.
-If you are a beginner or new to Python, I suggest you install EPD (pacakge manager for Python and its tools):
+## Testing
-`bash epd-7.3-2-rh5-x86.sh` ('rh3' for older operating systems) or just use the `.dmg` image for OSX.
+```bash
+pip install -e '.[qt,dev]'
+pytest
+```
+
+The suite never opens a window — it builds the trait objects and inspects the
+Matplotlib figure directly — so it runs headless. On a machine with no display,
+set `QT_QPA_PLATFORM=offscreen`.
+
+```bash
+ruff check . # lint
+ruff format . # format
+```
-## Running GUI
+## Troubleshooting
-Run `ipython` then `run main.py` or `python main.py` outright. If you get errors, you will likely also have to install wxpython. Install via `conda install wxpython`. If you are on Mac OSX, you then run via `pythonw main.py`. On Ubuntu you just run `python main.py`.
+| Symptom | Cause and fix |
+| --- | --- |
+| `ImportError` naming a toolkit on startup | No GUI toolkit installed. `pip install -e '.[qt]'`. |
+| Black backgrounds, misdrawn widgets | TraitsUI older than 7.2. Upgrade it. |
+| Mayavi tab shows an install hint | Expected without Mayavi. `pip install -e '.[qt,3d]'` to enable it. |
+| Wrong toolkit picked when both installed | Set `ETS_TOOLKIT=qt` or `ETS_TOOLKIT=wx`. |
+| Window does not focus on macOS (wx) | Launch with `pythonw main.py`, or use the Qt backend. |
+| `qt.qpa.plugin` errors on a headless Linux box | Set `QT_QPA_PLATFORM=offscreen`. |
-## Adding your own features.
+## Further reading
-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 will be giving a few introductory sessions on my [blog](http://www.brendangriffen.com) as well.
+- [Traits documentation](https://docs.enthought.com/traits/)
+- [TraitsUI documentation](https://docs.enthought.com/traitsui/) and its
+ [tutorials](https://docs.enthought.com/traitsui/tutorials/index.html)
+- [Mayavi documentation](https://docs.enthought.com/mayavi/mayavi/)
## Screenshots
-
+These predate the current version — the layout and 2D display are unchanged,
+but the second tab now holds the signal generator.
+
+
-An example of the some of the features within Traits.
+An example of some of the features within Traits.

-Gnarly, dynamic updates!
+Gnarly, dynamic updates!

-Example of enabling options when particular criteria are met.
+Enabling options when particular criteria are met.

+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/__init__.py b/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/firstcalc.py b/firstcalc.py
deleted file mode 100644
index 898de06..0000000
--- a/firstcalc.py
+++ /dev/null
@@ -1,94 +0,0 @@
-from Common import *
-
-class FirstCalc(HasTraits):
-
- # Add Traits objects
- option = Bool(True)
- rangex = Range(1,10,5)
- yval = Float()
- listoptions = Enum(['Option 1', 'Option 2','Option 2'])
- 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())
- 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.)
-
- #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')
- )
-
- 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='-')
- wx.CallAfter(self.main.display.canvas.draw)
-
- 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='-')
- wx.CallAfter(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')
- wx.CallAfter(self.main.display.canvas.draw)
-
- def _rangex_changed(self):
- self.yval = 1./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
-
-
diff --git a/main.py b/main.py
index cc0acd3..7936b43 100644
--- a/main.py
+++ b/main.py
@@ -1,90 +1,11 @@
+#!/usr/bin/env python
+"""Launcher kept at the repository root so ``python main.py`` still works.
-from firstcalc import FirstCalc
-from secondcalc import SecondCalc
+The application itself lives in the :mod:`pyguitemplate` package; see
+``pyguitemplate/app.py``.
+"""
-from tvtk.pyface.scene_editor import SceneEditor
-from mayavi.tools.mlab_scene_model import MlabSceneModel
-from mayavi.core.ui.mayavi_scene import MayaviScene
+from pyguitemplate.__main__ import main
-from Common import *
-
-class ApplicationMain(HasTraits):
-
- scene = Instance(MlabSceneModel, ())
-
- firstcalc = Instance(FirstCalc)
- secondcalc = Instance(SecondCalc)
- display = Instance(Figure)
- markercolor = ColorTrait
- markerstyle = Enum(['+',',','*','s','p','d','o'])
- markersize = Range(0,10,2)
-
- left_panel = Tabbed(Group(VGroup(Item('display', editor=MPLFigureEditor(),show_label=False, resizable=True)),
- HGroup(Item(name='markercolor', label="Color", style="custom",springy=True),
- Item(name='markerstyle', label="Marker",springy=True),
- Item(name='markersize', label="Size",springy=True)), label='Display'),
- Item(name='scene',label='Mayavi',editor=SceneEditor(scene_class=MayaviScene)),show_labels=False)
-
- right_panel = Tabbed(Item('firstcalc', style='custom', label='First Tab',show_label=False),
- Item('secondcalc', style='custom', label='Second Tab',show_label=False))
-
- view = View(HSplit(left_panel,
- right_panel),
- width = 1280,
- height = 750,
- resizable = True,
- title="My First Python GUI Interface"
- )
-
- def _display_default(self):
- """Initialises 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)
-
- # Set matplotlib canvas colour to be white
- rect = figure.patch
- rect.set_facecolor('w')
- return figure
-
- def _firstcalc_default(self):
- # Initialize halos the way we want to.
- # Pass a reference of main (e.g. self) downwards
- return FirstCalc(self)
-
- def _secondcalc_default(self):
- # Initialize halos the way we want to.
- # Pass a reference of main (e.g. self) downwards
- return SecondCalc(self)
-
- def _markercolor_changed(self):
- ax = self.display.axes[0]
- if hasattr(self, 'display_points'):
- self.display_points.set_color(self.markercolor)
- self.display_points.set_markeredgecolor(self.markercolor)
- wx.CallAfter(self.display.canvas.draw)
-
- def _markerstyle_changed(self):
- ax = self.display.axes[0]
- if hasattr(self, 'display_points'):
- self.display_points.set_marker(self.markerstyle)
- wx.CallAfter(self.display.canvas.draw)
-
- def _markersize_changed(self):
- ax = self.display.axes[0]
- if hasattr(self, 'display_points'):
- self.display_points.set_markersize(self.markersize)
- wx.CallAfter(self.display.canvas.draw)
-
- def __init__(self, **kwargs):
- self.markercolor = 'blue'
- self.markersize = 2
- self.markerstyle = 'o'
-
-if __name__ == '__main__':
- app = ApplicationMain()
- app.configure_traits()
+if __name__ == "__main__":
+ main()
diff --git a/pyguitemplate/__init__.py b/pyguitemplate/__init__.py
new file mode 100644
index 0000000..37cb284
--- /dev/null
+++ b/pyguitemplate/__init__.py
@@ -0,0 +1,5 @@
+"""A small, batteries-included starting point for Traits/TraitsUI desktop apps."""
+
+__version__ = "1.0.0"
+
+__all__ = ["__version__"]
diff --git a/pyguitemplate/__main__.py b/pyguitemplate/__main__.py
new file mode 100644
index 0000000..a4ccd21
--- /dev/null
+++ b/pyguitemplate/__main__.py
@@ -0,0 +1,12 @@
+"""Entry point: ``python -m pyguitemplate`` (or ``python main.py``)."""
+
+from pyguitemplate.app import ApplicationMain
+
+
+def main():
+ """Build the main window and hand control to the GUI event loop."""
+ ApplicationMain().configure_traits()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyguitemplate/app.py b/pyguitemplate/app.py
new file mode 100644
index 0000000..fd860e3
--- /dev/null
+++ b/pyguitemplate/app.py
@@ -0,0 +1,147 @@
+"""The main application window.
+
+The window is split in two: a tabbed display area on the left (a Matplotlib
+canvas and a Mayavi scene) and a tabbed set of control panels on the right.
+Each panel is handed a reference to this object so it can drive the display.
+"""
+
+from matplotlib.figure import Figure
+from traits.api import Any, Enum, HasTraits, Instance, Range
+from traitsui.api import (
+ ColorTrait,
+ Group,
+ HGroup,
+ HSplit,
+ Item,
+ Tabbed,
+ VGroup,
+ View,
+)
+
+from pyguitemplate.colors import to_mpl_color
+from pyguitemplate.mpl_editor import MPLFigureEditor, schedule_redraw
+from pyguitemplate.panels.first_panel import FirstPanel
+from pyguitemplate.panels.second_panel import SecondPanel
+from pyguitemplate.scene import make_scene_tab
+
+__all__ = ["ApplicationMain"]
+
+
+class ApplicationMain(HasTraits):
+ #: Either a live Mayavi scene or an install-hint placeholder.
+ scene_tab = Instance(HasTraits)
+
+ first_panel = Instance(FirstPanel)
+ second_panel = Instance(SecondPanel)
+ display = Instance(Figure)
+ # Defaults are declared here rather than assigned in __init__, so that
+ # every instance is fully initialised before any handler can run.
+ markercolor = ColorTrait("blue")
+ markerstyle = Enum("o", ["+", ",", "*", "s", "p", "d", "o"])
+ markersize = Range(1, 10, 4)
+
+ #: The Line2D currently drawn on the 2D display, or None before the first
+ #: plot. The marker controls above operate on it.
+ display_points = Any()
+
+ left_panel = Tabbed(
+ Group(
+ VGroup(
+ Item(
+ "display",
+ editor=MPLFigureEditor(),
+ show_label=False,
+ resizable=True,
+ )
+ ),
+ HGroup(
+ Item(name="markercolor", label="Color", style="custom", springy=True),
+ Item(name="markerstyle", label="Marker", springy=True),
+ Item(name="markersize", label="Size", springy=True),
+ ),
+ label="Display",
+ ),
+ Item(name="scene_tab", label="Mayavi", style="custom", resizable=True),
+ show_labels=False,
+ )
+
+ right_panel = Tabbed(
+ Item("first_panel", style="custom", label="First Tab", show_label=False),
+ Item("second_panel", style="custom", label="Second Tab", show_label=False),
+ )
+
+ view = View(
+ HSplit(left_panel, right_panel),
+ width=1280,
+ height=750,
+ resizable=True,
+ title="My First Python GUI Interface",
+ )
+
+ def _display_default(self):
+ """Initialises the display."""
+ figure = Figure()
+ ax = figure.add_subplot(111)
+ ax.set_xlabel("X")
+ ax.set_ylabel("Y")
+ ax.set_xlim(0, 1)
+ ax.set_ylim(0, 1)
+
+ # Set matplotlib canvas colour to be white
+ figure.patch.set_facecolor("w")
+ return figure
+
+ def _scene_tab_default(self):
+ return make_scene_tab()
+
+ def _first_panel_default(self):
+ # Pass a reference to main (e.g. self) downwards so the panel can
+ # reach the shared display.
+ return FirstPanel(self)
+
+ def _second_panel_default(self):
+ return SecondPanel(self)
+
+ def plot(self, x, y, linestyle="-"):
+ """Draw ``y`` against ``x`` on the 2D display, replacing what is there.
+
+ Panels call this rather than touching the figure themselves, so the
+ marker controls always have a line to act on.
+ """
+ figure = self.display
+ figure.clear()
+ axes = figure.add_subplot(111)
+ axes.set_xlabel("X")
+ axes.set_ylabel("Y")
+
+ (self.display_points,) = axes.plot(x, y, linestyle=linestyle)
+ self.apply_marker_style(redraw=False)
+ schedule_redraw(figure)
+ return self.display_points
+
+ def apply_marker_style(self, redraw=True):
+ """Push the current colour/marker/size traits onto the plotted line."""
+ line = self.display_points
+ if line is None:
+ return
+
+ # ColorTrait hands back a QColor or wx.Colour, neither of which
+ # Matplotlib accepts, so normalise first.
+ color = to_mpl_color(self.markercolor)
+ line.set_color(color)
+ line.set_markeredgecolor(color)
+ line.set_marker(self.markerstyle)
+ line.set_markersize(self.markersize)
+ line.set_markeredgewidth(0.0)
+
+ if redraw:
+ schedule_redraw(self.display)
+
+ def _markercolor_changed(self):
+ self.apply_marker_style()
+
+ def _markerstyle_changed(self):
+ self.apply_marker_style()
+
+ def _markersize_changed(self):
+ self.apply_marker_style()
diff --git a/pyguitemplate/colors.py b/pyguitemplate/colors.py
new file mode 100644
index 0000000..e0fb735
--- /dev/null
+++ b/pyguitemplate/colors.py
@@ -0,0 +1,52 @@
+"""Translate TraitsUI colour values into something Matplotlib understands.
+
+``ColorTrait`` stores a *toolkit-native* object -- a ``QColor`` under Qt, a
+``wx.Colour`` under wx -- and Matplotlib rejects both. Anything read off a
+colour trait has to pass through :func:`to_mpl_color` before it reaches a
+plotting call.
+"""
+
+__all__ = ["to_mpl_color"]
+
+DEFAULT_COLOR = "blue"
+
+
+def _from_sequence(components):
+ """Normalise an (r, g, b[, a]) sequence to Matplotlib's 0-1 floats."""
+ if not 3 <= len(components) <= 4:
+ raise ValueError(f"expected 3 or 4 colour components, got {len(components)}")
+ # Toolkits report 0-255 integers; Matplotlib wants 0-1 floats.
+ if all(isinstance(value, int) for value in components):
+ return tuple(value / 255.0 for value in components)
+ return tuple(float(value) for value in components)
+
+
+def to_mpl_color(color, default=DEFAULT_COLOR):
+ """Return a Matplotlib-acceptable colour for a ``ColorTrait`` value.
+
+ Handles plain strings (``"blue"``, ``"#0000ff"``), Qt ``QColor``,
+ wx ``wx.Colour`` and raw ``(r, g, b[, a])`` sequences.
+ """
+ if color is None:
+ return default
+
+ if isinstance(color, str):
+ return color
+
+ # Qt: QColor.name() -> "#rrggbb".
+ name = getattr(color, "name", None)
+ if callable(name):
+ value = name()
+ if isinstance(value, str) and value.startswith("#"):
+ return value
+
+ # wx: wx.Colour.Get() -> (r, g, b, a) as 0-255 integers.
+ get = getattr(color, "Get", None)
+ if callable(get):
+ return _from_sequence(tuple(get()))
+
+ try:
+ components = tuple(color)
+ except TypeError as exc:
+ raise ValueError(f"cannot interpret {color!r} as a colour") from exc
+ return _from_sequence(components)
diff --git a/pyguitemplate/mpl_editor.py b/pyguitemplate/mpl_editor.py
new file mode 100644
index 0000000..c0ba12e
--- /dev/null
+++ b/pyguitemplate/mpl_editor.py
@@ -0,0 +1,107 @@
+"""A TraitsUI editor that embeds a Matplotlib figure.
+
+TraitsUI editors are toolkit specific, but the only part of this editor that
+actually differs between toolkits is the handful of lines that build the
+canvas widget. The active ETS toolkit is resolved once at import time and the
+matching implementation is selected, so the same template runs under either Qt
+or wx without edits.
+"""
+
+# Importing pyface.toolkit resolves and caches the active ETS toolkit, which
+# guarantees ETSConfig.toolkit is populated by the time we branch on it below.
+import pyface.toolkit # noqa: F401 (imported for its side effect)
+from pyface.api import GUI
+from traits.etsconfig.api import ETSConfig
+from traitsui.basic_editor_factory import BasicEditorFactory
+
+__all__ = ["MPLFigureEditor", "schedule_redraw"]
+
+TOOLKIT = ETSConfig.toolkit
+
+
+def _using_qt():
+ # ETSConfig reports "qt" on TraitsUI 8 and "qt4" on TraitsUI 7.
+ return TOOLKIT.startswith("qt")
+
+
+if _using_qt():
+ try:
+ from traitsui.qt.editor import Editor
+ except ImportError: # TraitsUI < 8
+ from traitsui.qt4.editor import Editor
+
+ from matplotlib.backends.backend_qtagg import (
+ FigureCanvasQTAgg,
+ NavigationToolbar2QT,
+ )
+ from pyface.qt import QtGui
+
+ def _create_canvas(parent, figure):
+ """Build a Qt panel holding the figure canvas and its toolbar."""
+ panel = QtGui.QWidget()
+ layout = QtGui.QVBoxLayout(panel)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ canvas = FigureCanvasQTAgg(figure)
+ canvas.setParent(panel)
+ toolbar = NavigationToolbar2QT(canvas, panel)
+
+ layout.addWidget(canvas)
+ layout.addWidget(toolbar)
+ canvas.setMinimumSize(10, 10)
+ return panel
+
+else:
+ import wx
+ from matplotlib.backends.backend_wxagg import (
+ FigureCanvasWxAgg,
+ NavigationToolbar2WxAgg,
+ )
+ from traitsui.wx.editor import Editor
+
+ def _create_canvas(parent, figure):
+ """Build a wx panel holding the figure canvas and its toolbar."""
+ panel = wx.Panel(parent, -1, style=wx.CLIP_CHILDREN)
+ sizer = wx.BoxSizer(wx.VERTICAL)
+ panel.SetSizer(sizer)
+
+ canvas = FigureCanvasWxAgg(panel, -1, figure)
+ sizer.Add(canvas, 1, wx.LEFT | wx.TOP | wx.GROW)
+ toolbar = NavigationToolbar2WxAgg(canvas)
+ sizer.Add(toolbar, 0, wx.EXPAND)
+
+ canvas.SetMinSize((10, 10))
+ return panel
+
+
+def schedule_redraw(figure):
+ """Repaint ``figure`` on the next pass of the GUI event loop.
+
+ Trait change handlers can fire from outside the UI thread, and drawing a
+ canvas directly from there is not safe under either toolkit. Deferring via
+ :meth:`GUI.invoke_later` is the toolkit-neutral equivalent of the
+ ``wx.CallAfter`` calls this template used to make.
+ """
+ canvas = getattr(figure, "canvas", None)
+ if canvas is None:
+ return
+ GUI.invoke_later(canvas.draw_idle)
+
+
+class _MPLFigureEditor(Editor):
+ """Editor implementation: wraps the figure held in ``self.value``."""
+
+ scrollable = True
+
+ def init(self, parent):
+ self.control = _create_canvas(parent, self.value)
+ self.set_tooltip()
+
+ def update_editor(self):
+ """The figure is mutated in place by the panels, so nothing to sync."""
+
+
+class MPLFigureEditor(BasicEditorFactory):
+ """Editor factory. Use as ``Item('figure', editor=MPLFigureEditor())``."""
+
+ klass = _MPLFigureEditor
diff --git a/pyguitemplate/panels/__init__.py b/pyguitemplate/panels/__init__.py
new file mode 100644
index 0000000..5de7353
--- /dev/null
+++ b/pyguitemplate/panels/__init__.py
@@ -0,0 +1,6 @@
+"""Control panels shown in the right-hand tab area of the main window."""
+
+from pyguitemplate.panels.first_panel import FirstPanel
+from pyguitemplate.panels.second_panel import SecondPanel
+
+__all__ = ["FirstPanel", "SecondPanel"]
diff --git a/pyguitemplate/panels/first_panel.py b/pyguitemplate/panels/first_panel.py
new file mode 100644
index 0000000..e3bb367
--- /dev/null
+++ b/pyguitemplate/panels/first_panel.py
@@ -0,0 +1,98 @@
+"""Example control panel: a tour of the common Traits editors."""
+
+import os
+import random
+
+import numpy as np
+from traits.api import (
+ Any,
+ Bool,
+ Button,
+ Directory,
+ Enum,
+ Float,
+ HasTraits,
+ Int,
+ Range,
+ Str,
+)
+from traitsui.api import Group, Item, View
+
+__all__ = ["FirstPanel"]
+
+
+class FirstPanel(HasTraits):
+ #: The ApplicationMain instance that owns this panel.
+ main = Any()
+
+ # Add Traits objects
+ option = Bool(True)
+ rangex = Range(1, 10, 5)
+ yval = Float()
+ 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())
+ button1 = Button("Button 1 Name")
+ floatval = Float()
+ changedcount = Int()
+ plotbutton = Button("Plot Me!")
+ # Range is (low, high, default) -- the default must lie inside the bounds.
+ yintcept = Range(0.0, 10.0, 5.0)
+ gradient = Range(0.0, 10.0, 5.0)
+
+ # 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",
+ ),
+ )
+
+ def replot(self):
+ """Draw the current straight line on the shared display."""
+ x = np.arange(10)
+ self.main.plot(x, self.gradient * x + self.yintcept)
+
+ def _gradient_changed(self):
+ self.replot()
+
+ def _yintcept_changed(self):
+ self.replot()
+
+ def _plotbutton_fired(self):
+ self.replot()
+
+ def _rangex_changed(self):
+ 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):
+ # kwargs must reach HasTraits or trait values passed by the caller are
+ # silently dropped.
+ super().__init__(**kwargs)
+ self.main = main
diff --git a/pyguitemplate/panels/second_panel.py b/pyguitemplate/panels/second_panel.py
new file mode 100644
index 0000000..04a3ee8
--- /dev/null
+++ b/pyguitemplate/panels/second_panel.py
@@ -0,0 +1,130 @@
+"""Example control panel: a small signal generator.
+
+Where :mod:`~pyguitemplate.panels.first_panel` is a tour of the individual
+editors, this panel shows how they fit together in something that does real
+work -- deriving data from several traits, pushing it to the shared display,
+and writing it out to disk.
+"""
+
+import numpy as np
+from traits.api import (
+ Any,
+ Bool,
+ Button,
+ Enum,
+ File,
+ HasTraits,
+ Range,
+ Str,
+ observe,
+)
+from traitsui.api import Group, HGroup, Item, View
+
+__all__ = ["SecondPanel"]
+
+WAVEFORMS = ["sine", "cosine", "damped sine", "square"]
+
+
+class SecondPanel(HasTraits):
+ #: The ApplicationMain instance that owns this panel.
+ main = Any()
+
+ waveform = Enum("sine", WAVEFORMS)
+ amplitude = Range(0.1, 5.0, 1.0)
+ frequency = Range(0.1, 5.0, 1.0)
+ noise = Range(0.0, 1.0, 0.0)
+ npoints = Range(10, 2000, 200)
+
+ autoplot = Bool(True)
+ plotbutton = Button("Plot signal")
+
+ exportpath = File()
+ exportbutton = Button("Export CSV")
+
+ status = Str("Ready.")
+
+ view = View(
+ Group(
+ Item(name="waveform", label="Waveform"),
+ Item(name="amplitude", label="Amplitude"),
+ Item(name="frequency", label="Frequency"),
+ Item(name="noise", label="Noise"),
+ Item(name="npoints", label="Points"),
+ label="Signal",
+ show_border=True,
+ ),
+ Group(
+ HGroup(
+ Item(name="autoplot", label="Redraw on change"),
+ # Manual plotting only makes sense when autoplot is off.
+ Item(name="plotbutton", show_label=False, enabled_when="not autoplot"),
+ ),
+ label="Display",
+ show_border=True,
+ ),
+ Group(
+ Item(name="exportpath", label="File"),
+ Item(
+ name="exportbutton",
+ show_label=False,
+ enabled_when="exportpath != ''",
+ ),
+ label="Export",
+ show_border=True,
+ ),
+ Item(name="status", style="readonly", show_label=False),
+ resizable=True,
+ )
+
+ def signal(self):
+ """Return the (x, y) arrays described by the current settings."""
+ x = np.linspace(0.0, 2.0 * np.pi, self.npoints)
+ phase = self.frequency * x
+
+ if self.waveform == "sine":
+ y = np.sin(phase)
+ elif self.waveform == "cosine":
+ y = np.cos(phase)
+ elif self.waveform == "damped sine":
+ y = np.sin(phase) * np.exp(-x / 3.0)
+ else: # square
+ y = np.sign(np.sin(phase))
+
+ y = self.amplitude * y
+ if self.noise:
+ rng = np.random.default_rng()
+ y = y + rng.normal(scale=self.noise, size=y.shape)
+ return x, y
+
+ def replot(self):
+ """Draw the current signal on the shared display."""
+ x, y = self.signal()
+ self.main.plot(x, y)
+ self.status = f"Plotted {self.npoints} points of {self.waveform}."
+
+ @observe("waveform, amplitude, frequency, noise, npoints")
+ def _settings_changed(self, event):
+ if self.autoplot:
+ self.replot()
+
+ def _plotbutton_fired(self):
+ self.replot()
+
+ def _exportbutton_fired(self):
+ x, y = self.signal()
+ try:
+ np.savetxt(
+ self.exportpath,
+ np.column_stack([x, y]),
+ delimiter=",",
+ header="x,y",
+ comments="",
+ )
+ except OSError as exc:
+ self.status = f"Export failed: {exc}"
+ return
+ self.status = f"Wrote {len(x)} rows to {self.exportpath}"
+
+ def __init__(self, main, **kwargs):
+ super().__init__(**kwargs)
+ self.main = main
diff --git a/pyguitemplate/scene.py b/pyguitemplate/scene.py
new file mode 100644
index 0000000..14a7d22
--- /dev/null
+++ b/pyguitemplate/scene.py
@@ -0,0 +1,78 @@
+"""The 3D scene tab, with a graceful fallback when Mayavi is unavailable.
+
+Mayavi pulls in VTK, which is the largest and most install-fragile dependency
+in this stack. Rather than making the whole application unimportable when it is
+missing, the scene tab degrades to a panel explaining how to install it.
+"""
+
+from traits.api import HasTraits, Str
+from traitsui.api import Item, View
+
+__all__ = ["MAYAVI_AVAILABLE", "SceneTab", "MissingSceneTab", "make_scene_tab"]
+
+try:
+ from mayavi.core.ui.mayavi_scene import MayaviScene
+ from mayavi.tools.mlab_scene_model import MlabSceneModel
+ from traits.api import Instance
+ from tvtk.pyface.scene_editor import SceneEditor
+
+ MAYAVI_AVAILABLE = True
+except ImportError: # pragma: no cover - depends on the local environment
+ MAYAVI_AVAILABLE = False
+
+
+INSTALL_HINT = (
+ "The 3D scene needs Mayavi, which is not installed.\n\n"
+ "Install it with:\n"
+ " pip install 'pyguitemplate[3d]'\n\n"
+ "Everything else in this application works without it."
+)
+
+
+class MissingSceneTab(HasTraits):
+ """Stand-in shown when Mayavi cannot be imported."""
+
+ message = Str(INSTALL_HINT)
+
+ view = View(
+ Item("message", style="readonly", show_label=False, resizable=True),
+ resizable=True,
+ )
+
+ def plot_demo(self):
+ """No-op so callers do not have to check which tab they got."""
+
+
+if MAYAVI_AVAILABLE:
+
+ class SceneTab(HasTraits):
+ """A live Mayavi scene."""
+
+ scene = Instance(MlabSceneModel, ())
+
+ view = View(
+ Item(
+ "scene",
+ editor=SceneEditor(scene_class=MayaviScene),
+ show_label=False,
+ resizable=True,
+ ),
+ resizable=True,
+ )
+
+ def plot_demo(self):
+ """Render a simple surface so the tab is not blank on first open."""
+ import numpy as np
+
+ x, y = np.mgrid[-3:3:60j, -3:3:60j]
+ z = np.sin(x * y) * np.exp(-0.1 * (x**2 + y**2))
+ self.scene.mlab.surf(x, y, z)
+
+else:
+ # Same name either way, so the rest of the app does not branch.
+ SceneTab = MissingSceneTab
+
+
+def make_scene_tab():
+ """Return a scene tab appropriate to what is installed."""
+ return SceneTab()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..326d391
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,67 @@
+[build-system]
+requires = ["setuptools>=64"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "pyguitemplate"
+description = "A template for building desktop GUIs in Python with Traits, TraitsUI, Matplotlib and Mayavi"
+readme = "README.md"
+license = { file = "LICENSE" }
+authors = [{ name = "Brendan Griffen" }]
+requires-python = ">=3.9"
+dynamic = ["version"]
+keywords = ["gui", "traits", "traitsui", "matplotlib", "mayavi", "template"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Environment :: X11 Applications :: Qt",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Topic :: Scientific/Engineering :: Visualization",
+]
+
+# The GUI toolkit is deliberately not a hard requirement: pick exactly one of
+# the "qt" or "wx" extras below. Installing both leaves the choice of backend
+# ambiguous (set the ETS_TOOLKIT environment variable to disambiguate).
+dependencies = [
+ "traits>=6.2",
+ "traitsui>=7.2",
+ "pyface>=7.4",
+ "matplotlib>=3.5",
+ "numpy>=1.20",
+]
+
+[project.optional-dependencies]
+qt = ["pyside6>=6.2"]
+wx = ["wxPython>=4.1"]
+# 3D scene support. Pulls in VTK, which is a large download and the most
+# fragile dependency in the stack; the app runs without it.
+3d = ["mayavi>=4.8"]
+dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4"]
+
+[project.scripts]
+pyguitemplate = "pyguitemplate.__main__:main"
+
+[project.urls]
+Homepage = "https://github.com/bgriffen/PythonGUITemplate"
+Issues = "https://github.com/bgriffen/PythonGUITemplate/issues"
+
+[tool.setuptools]
+packages = ["pyguitemplate", "pyguitemplate.panels"]
+
+[tool.setuptools.dynamic]
+version = { attr = "pyguitemplate.__version__" }
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "-ra"
+
+[tool.ruff]
+line-length = 88
+target-version = "py39"
+# Ruff formats fenced code blocks in Markdown; the README's snippets are
+# deliberately shown as users would type them.
+extend-exclude = ["*.md"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
diff --git a/secondcalc.py b/secondcalc.py
deleted file mode 100644
index 0b2f3ca..0000000
--- a/secondcalc.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from Common import *
-
-class SecondCalc(HasTraits):
-
- view = View()
-
-
- def __init__(self, main, **kwargs):
- HasTraits.__init__(self)
- self.main = main
-
\ No newline at end of file
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..56d1747
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,19 @@
+"""Shared test configuration.
+
+The tests never open a window -- they build the trait objects and inspect the
+Matplotlib figure directly -- so they run headless on CI without an X server.
+"""
+
+import matplotlib
+import pytest
+
+# Must be selected before any figure is created.
+matplotlib.use("Agg")
+
+from pyguitemplate.app import ApplicationMain # noqa: E402
+
+
+@pytest.fixture
+def app():
+ """A fully constructed main window object (never shown)."""
+ return ApplicationMain()
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..4ac7ff0
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,82 @@
+"""Tests for the main window object and the shared display."""
+
+import matplotlib.colors as mcolors
+import numpy as np
+
+from pyguitemplate.app import ApplicationMain
+
+
+def test_constructs_with_working_defaults(app):
+ assert app.markerstyle == "o"
+ assert app.markersize == 4
+ assert app.display is not None
+ assert app.display_points is None
+
+
+def test_markercolor_is_a_real_trait():
+ """Regression: it was 'ColorTrait' without parentheses, so it bound as a
+ method and the colour control was inert."""
+ assert "markercolor" in ApplicationMain.class_traits()
+ assert not callable(ApplicationMain().markercolor)
+
+
+def test_plot_records_the_line_it_draws(app):
+ x = np.arange(5)
+ line = app.plot(x, 2 * x)
+
+ assert app.display_points is line
+ assert list(line.get_xdata()) == list(x)
+ assert list(line.get_ydata()) == list(2 * x)
+
+
+def test_plot_replaces_rather_than_accumulates(app):
+ app.plot(np.arange(5), np.arange(5))
+ app.plot(np.arange(3), np.arange(3))
+
+ assert len(app.display.axes) == 1
+ assert len(app.display.axes[0].lines) == 1
+
+
+def test_marker_controls_update_the_plotted_line(app):
+ """Regression: all three handlers were guarded on a display_points
+ attribute that was never assigned, so they could never fire."""
+ app.plot(np.arange(5), np.arange(5))
+ line = app.display_points
+
+ app.markercolor = "red"
+ assert mcolors.to_rgba(line.get_color()) == mcolors.to_rgba("red")
+ assert mcolors.to_rgba(line.get_markeredgecolor()) == mcolors.to_rgba("red")
+
+ app.markerstyle = "s"
+ assert line.get_marker() == "s"
+
+ app.markersize = 9
+ assert line.get_markersize() == 9
+
+
+def test_marker_controls_are_safe_before_anything_is_plotted(app):
+ app.markercolor = "red"
+ app.markerstyle = "s"
+ app.markersize = 7
+
+ assert app.display_points is None
+
+
+def test_new_plots_inherit_the_current_marker_style(app):
+ app.markerstyle = "d"
+ app.markersize = 8
+ app.plot(np.arange(4), np.arange(4))
+
+ assert app.display_points.get_marker() == "d"
+ assert app.display_points.get_markersize() == 8
+
+
+def test_panels_are_wired_to_the_main_window(app):
+ assert app.first_panel.main is app
+ assert app.second_panel.main is app
+
+
+def test_scene_tab_is_always_present(app):
+ """Present whether or not Mayavi is installed."""
+ assert app.scene_tab is not None
+ assert hasattr(app.scene_tab, "plot_demo")
diff --git a/tests/test_colors.py b/tests/test_colors.py
new file mode 100644
index 0000000..4a9f90e
--- /dev/null
+++ b/tests/test_colors.py
@@ -0,0 +1,62 @@
+"""Tests for the ColorTrait -> Matplotlib colour conversion."""
+
+import matplotlib.colors as mcolors
+import pytest
+
+from pyguitemplate.colors import to_mpl_color
+
+
+class FakeQColor:
+ """Stands in for Qt's QColor, which exposes name() -> '#rrggbb'."""
+
+ def name(self):
+ return "#aabbcc"
+
+
+class FakeWxColour:
+ """Stands in for wx.Colour, which exposes Get() -> 0-255 integers."""
+
+ def Get(self):
+ return (255, 0, 0, 255)
+
+
+def test_passes_strings_through():
+ assert to_mpl_color("red") == "red"
+ assert to_mpl_color("#0000ff") == "#0000ff"
+
+
+def test_converts_qcolor():
+ assert to_mpl_color(FakeQColor()) == "#aabbcc"
+
+
+def test_converts_wx_colour():
+ assert to_mpl_color(FakeWxColour()) == (1.0, 0.0, 0.0, 1.0)
+
+
+def test_scales_integer_sequences_to_unit_range():
+ assert to_mpl_color((0, 128, 255)) == (0.0, 128 / 255.0, 1.0)
+
+
+def test_leaves_float_sequences_alone():
+ assert to_mpl_color((0.0, 0.5, 1.0)) == (0.0, 0.5, 1.0)
+
+
+def test_none_falls_back_to_the_default():
+ assert to_mpl_color(None) == "blue"
+ assert to_mpl_color(None, default="green") == "green"
+
+
+def test_rejects_values_it_cannot_interpret():
+ with pytest.raises(ValueError):
+ to_mpl_color(object())
+
+ with pytest.raises(ValueError):
+ to_mpl_color((1, 2))
+
+
+@pytest.mark.parametrize(
+ "value", ["red", FakeQColor(), FakeWxColour(), (0, 128, 255), (0.0, 0.5, 1.0)]
+)
+def test_results_are_accepted_by_matplotlib(value):
+ """The whole point: whatever comes back must survive to_rgba()."""
+ assert mcolors.to_rgba(to_mpl_color(value))
diff --git a/tests/test_first_panel.py b/tests/test_first_panel.py
new file mode 100644
index 0000000..92689b9
--- /dev/null
+++ b/tests/test_first_panel.py
@@ -0,0 +1,72 @@
+"""Tests for the first example panel."""
+
+import pytest
+from traits.api import TraitError
+
+from pyguitemplate.panels.first_panel import FirstPanel
+
+
+def test_range_defaults_lie_inside_their_bounds(app):
+ """Regression: these were Range(0.0, 5.0, 10.0) -- Range takes
+ (low, high, default), so both traits initialised out of bounds."""
+ panel = app.first_panel
+
+ assert 0.0 <= panel.gradient <= 10.0
+ assert 0.0 <= panel.yintcept <= 10.0
+
+
+def test_ranges_accept_values_across_their_span(app):
+ panel = app.first_panel
+
+ panel.yintcept = 7.0 # previously raised TraitError
+ assert panel.yintcept == 7.0
+
+ with pytest.raises(TraitError):
+ panel.gradient = 99.0
+
+
+def test_option_list_has_no_duplicates():
+ """Regression: 'Option 2' was listed twice, making one entry unreachable."""
+ values = FirstPanel.class_traits()["listoptions"].handler.values
+
+ assert len(values) == len(set(values))
+
+
+def test_constructor_forwards_keyword_arguments(app):
+ """Regression: **kwargs were accepted then dropped on the floor."""
+ panel = FirstPanel(app, stringopt="custom")
+
+ assert panel.stringopt == "custom"
+ assert panel.main is app
+
+
+def test_rangex_drives_the_reciprocal_readout(app):
+ app.first_panel.rangex = 4
+
+ assert app.first_panel.yval == pytest.approx(0.25)
+
+
+def test_button_increments_the_counter_and_rolls_a_value(app):
+ panel = app.first_panel
+ before = panel.changedcount
+
+ panel.button1 = True # firing a Button trait
+
+ assert panel.changedcount == before + 1
+ assert 0.0 <= panel.floatval <= 1.0
+
+
+def test_changing_gradient_plots_the_line(app):
+ app.first_panel.gradient = 2.0
+
+ ydata = app.display_points.get_ydata()
+ assert ydata[0] == pytest.approx(app.first_panel.yintcept)
+ # y = gradient * x + intercept, sampled on arange(10)
+ assert ydata[1] - ydata[0] == pytest.approx(2.0)
+
+
+def test_plot_button_redraws(app):
+ """Regression: plotbutton had no handler, so pressing it did nothing."""
+ app.first_panel.plotbutton = True
+
+ assert app.display_points is not None
diff --git a/tests/test_scene_and_editor.py b/tests/test_scene_and_editor.py
new file mode 100644
index 0000000..258af95
--- /dev/null
+++ b/tests/test_scene_and_editor.py
@@ -0,0 +1,33 @@
+"""Tests for the optional 3D scene tab and the Matplotlib editor."""
+
+from matplotlib.figure import Figure
+
+from pyguitemplate import scene
+from pyguitemplate.mpl_editor import MPLFigureEditor, schedule_redraw
+
+
+def test_scene_tab_matches_what_is_installed():
+ tab = scene.make_scene_tab()
+
+ if scene.MAYAVI_AVAILABLE:
+ assert hasattr(tab, "scene")
+ else:
+ assert isinstance(tab, scene.MissingSceneTab)
+ assert "pip install" in tab.message
+
+
+def test_placeholder_plot_demo_is_a_safe_no_op():
+ """Callers should not have to check which variant they were handed."""
+ assert scene.MissingSceneTab().plot_demo() is None
+
+
+def test_editor_factory_is_constructible():
+ """Regression: the editor used to be importable only under wx."""
+ assert MPLFigureEditor().klass is not None
+
+
+def test_schedule_redraw_tolerates_a_figure_with_no_canvas():
+ figure = Figure()
+ figure.canvas = None
+
+ schedule_redraw(figure) # must not raise
diff --git a/tests/test_second_panel.py b/tests/test_second_panel.py
new file mode 100644
index 0000000..62b07f6
--- /dev/null
+++ b/tests/test_second_panel.py
@@ -0,0 +1,92 @@
+"""Tests for the second example panel (the signal generator)."""
+
+import numpy as np
+import pytest
+
+from pyguitemplate.panels.second_panel import WAVEFORMS
+
+
+@pytest.mark.parametrize("waveform", WAVEFORMS)
+def test_every_waveform_produces_a_finite_signal(app, waveform):
+ panel = app.second_panel
+ panel.waveform = waveform
+
+ x, y = panel.signal()
+
+ assert len(x) == len(y) == panel.npoints
+ assert np.all(np.isfinite(y))
+
+
+def test_amplitude_scales_the_signal(app):
+ panel = app.second_panel
+ panel.waveform = "sine"
+ panel.noise = 0.0
+
+ panel.amplitude = 1.0
+ quiet = np.abs(panel.signal()[1]).max()
+ panel.amplitude = 4.0
+ loud = np.abs(panel.signal()[1]).max()
+
+ assert loud == pytest.approx(4.0 * quiet)
+
+
+def test_noise_perturbs_an_otherwise_identical_signal(app):
+ panel = app.second_panel
+ panel.noise = 0.0
+ clean = panel.signal()[1]
+
+ panel.noise = 0.5
+ noisy = panel.signal()[1]
+
+ assert not np.allclose(clean, noisy)
+
+
+def test_autoplot_pushes_to_the_shared_display(app):
+ panel = app.second_panel
+ panel.autoplot = True
+
+ panel.frequency = 3.0
+
+ assert app.display_points is not None
+ assert len(app.display_points.get_xdata()) == panel.npoints
+
+
+def test_autoplot_off_leaves_the_display_alone(app):
+ panel = app.second_panel
+ panel.autoplot = False
+
+ panel.frequency = 3.0
+
+ assert app.display_points is None
+
+
+def test_manual_plot_button_works_when_autoplot_is_off(app):
+ panel = app.second_panel
+ panel.autoplot = False
+ panel.frequency = 3.0
+
+ panel.plotbutton = True
+
+ assert app.display_points is not None
+
+
+def test_export_writes_a_csv(app, tmp_path):
+ panel = app.second_panel
+ target = tmp_path / "signal.csv"
+ panel.exportpath = str(target)
+
+ panel.exportbutton = True
+
+ rows = np.loadtxt(target, delimiter=",", skiprows=1)
+ assert rows.shape == (panel.npoints, 2)
+ assert target.read_text().splitlines()[0] == "x,y"
+ assert "Wrote" in panel.status
+
+
+def test_export_reports_failure_instead_of_raising(app, tmp_path):
+ panel = app.second_panel
+ panel.exportpath = str(tmp_path / "missing-dir" / "signal.csv")
+
+ panel.exportbutton = True
+
+ assert panel.status.startswith("Export failed")