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. + +[![CI](https://github.com/bgriffen/PythonGUITemplate/actions/workflows/ci.yml/badge.svg)](https://github.com/bgriffen/PythonGUITemplate/actions/workflows/ci.yml) +[![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue)](https://www.python.org/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230)](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 -![Detault view upon launch.](http://www.brendangriffen.com/assets/pythongui/PythonGUIEx7-1024x599.png) +These predate the current version — the layout and 2D display are unchanged, +but the second tab now holds the signal generator. + +![Default view upon launch.](http://www.brendangriffen.com/assets/pythongui/PythonGUIEx7-1024x599.png) -An example of the some of the features within Traits. +An example of some of the features within Traits. ![Example 1](http://www.brendangriffen.com/assets/pythongui/PythonGUIEx4Zoom.png) -Gnarly, dynamic updates! +Gnarly, dynamic updates! ![Example 2](http://www.brendangriffen.com/assets/pythongui/PythonGUIEx5.png) -Example of enabling options when particular criteria are met. +Enabling options when particular criteria are met. ![Example 3](http://www.brendangriffen.com/assets/pythongui/PythonGUIEx6.png) +## 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")