From b032cda8c2612385a970eb97cde164f32018040c Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:11:08 +1000 Subject: [PATCH 01/10] chore: add .gitignore and MIT license The repository had neither, so build artefacts and virtualenvs were untracked-but-noisy and the licensing terms were unstated. --- .gitignore | 31 +++++++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE 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/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. From 0c2f72e54ab9e594e427cbf971a8a02c7ca002da Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:15:26 +1000 Subject: [PATCH 02/10] refactor: restructure into a package and drop the hard wx dependency Common.py acted as a shared 'import everything' bucket that every module pulled in with 'from Common import *', which made it impossible to tell where any given name came from. Imports are now explicit. The Matplotlib editor was written against wx only: it imported wx at module scope, subclassed traitsui.wx.editor.Editor and scheduled repaints with wx.CallAfter. It now resolves the active ETS toolkit at import time and builds either a Qt or a wx canvas, and repaints go through the neutral GUI.invoke_later. That makes Qt a viable backend, which matters because wxPython is by far the harder of the two to install. Also switches to matplotlib.figure.Figure so importing the app no longer drags in pyplot and its global backend state. Behaviour is otherwise unchanged; the bugs found during the audit are fixed in the commits that follow. --- Common.py | 60 -------------- __init__.py | 0 firstcalc.py | 94 --------------------- main.py | 95 ++------------------- pyguitemplate/__init__.py | 5 ++ pyguitemplate/__main__.py | 12 +++ pyguitemplate/app.py | 120 +++++++++++++++++++++++++++ pyguitemplate/mpl_editor.py | 108 ++++++++++++++++++++++++ pyguitemplate/panels/__init__.py | 6 ++ pyguitemplate/panels/first_panel.py | 114 +++++++++++++++++++++++++ pyguitemplate/panels/second_panel.py | 15 ++++ secondcalc.py | 11 --- 12 files changed, 388 insertions(+), 252 deletions(-) delete mode 100644 Common.py delete mode 100644 __init__.py delete mode 100644 firstcalc.py create mode 100644 pyguitemplate/__init__.py create mode 100644 pyguitemplate/__main__.py create mode 100644 pyguitemplate/app.py create mode 100644 pyguitemplate/mpl_editor.py create mode 100644 pyguitemplate/panels/__init__.py create mode 100644 pyguitemplate/panels/first_panel.py create mode 100644 pyguitemplate/panels/second_panel.py delete mode 100644 secondcalc.py 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/__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..36131e2 --- /dev/null +++ b/pyguitemplate/app.py @@ -0,0 +1,120 @@ +"""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 mayavi.core.ui.mayavi_scene import MayaviScene +from mayavi.tools.mlab_scene_model import MlabSceneModel +from traits.api import Enum, HasTraits, Instance, Range +from traitsui.api import ( + ColorTrait, + Group, + HGroup, + HSplit, + Item, + Tabbed, + VGroup, + View, +) +from tvtk.pyface.scene_editor import SceneEditor + +from pyguitemplate.mpl_editor import MPLFigureEditor, schedule_redraw +from pyguitemplate.panels.first_panel import FirstPanel +from pyguitemplate.panels.second_panel import SecondPanel + +__all__ = ["ApplicationMain"] + + +class ApplicationMain(HasTraits): + + scene = Instance(MlabSceneModel, ()) + + first_panel = Instance(FirstPanel) + second_panel = Instance(SecondPanel) + 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("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 _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 _markercolor_changed(self): + if hasattr(self, "display_points"): + self.display_points.set_color(self.markercolor) + self.display_points.set_markeredgecolor(self.markercolor) + schedule_redraw(self.display) + + def _markerstyle_changed(self): + if hasattr(self, "display_points"): + self.display_points.set_marker(self.markerstyle) + schedule_redraw(self.display) + + def _markersize_changed(self): + if hasattr(self, "display_points"): + self.display_points.set_markersize(self.markersize) + schedule_redraw(self.display) + + def __init__(self, **kwargs): + self.markercolor = "blue" + self.markersize = 2 + self.markerstyle = "o" diff --git a/pyguitemplate/mpl_editor.py b/pyguitemplate/mpl_editor.py new file mode 100644 index 0000000..879d7cd --- /dev/null +++ b/pyguitemplate/mpl_editor.py @@ -0,0 +1,108 @@ +"""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: + from traitsui.wx.editor import Editor + + import wx + from matplotlib.backends.backend_wxagg import ( + FigureCanvasWxAgg, + NavigationToolbar2WxAgg, + ) + + 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..157591f --- /dev/null +++ b/pyguitemplate/panels/first_panel.py @@ -0,0 +1,114 @@ +"""Example control panel: a tour of the common Traits editors.""" + +import os +import random + +import numpy as np +from traits.api import ( + Bool, + Button, + Directory, + Enum, + Float, + HasTraits, + Int, + Range, + Str, +) +from traitsui.api import Group, Item, View + +from pyguitemplate.mpl_editor import schedule_redraw + +__all__ = ["FirstPanel"] + + +class FirstPanel(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.0, 10.0) + gradient = Range(0.0, 5.0, 10.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 _gradient_changed(self): + y = self.gradient * np.array(range(10)) + self.yintcept + + figure = self.main.display + figure.clear() + ax = figure.add_subplot(111) + ax.plot( + np.array(range(10)), + y, + color=self.main.markercolor, + marker=self.main.markerstyle, + markersize=self.main.markersize, + markeredgewidth=0.0, + linestyle="-", + ) + schedule_redraw(figure) + + 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.plot( + np.array(range(10)), + y, + color=self.main.markercolor, + marker=self.main.markerstyle, + markersize=self.main.markersize, + markeredgewidth=0.0, + linestyle="-", + ) + schedule_redraw(figure) + + 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): + HasTraits.__init__(self) + self.main = main diff --git a/pyguitemplate/panels/second_panel.py b/pyguitemplate/panels/second_panel.py new file mode 100644 index 0000000..f93d857 --- /dev/null +++ b/pyguitemplate/panels/second_panel.py @@ -0,0 +1,15 @@ +"""Example control panel: intentionally empty, ready for your own controls.""" + +from traits.api import HasTraits +from traitsui.api import View + +__all__ = ["SecondPanel"] + + +class SecondPanel(HasTraits): + + view = View() + + def __init__(self, main, **kwargs): + HasTraits.__init__(self) + self.main = main 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 From 7450ab987e1ad4e7c6a7d5cc6f6e5fa020ee4678 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:17:24 +1000 Subject: [PATCH 03/10] build: add pyproject.toml with per-toolkit extras There was no dependency metadata of any kind, so the only install instructions were prose in the README. The GUI toolkit is exposed as mutually exclusive 'qt'/'wx' extras rather than a hard dependency, and Mayavi sits behind a '3d' extra because VTK is the heaviest and most install-fragile piece of the stack. --- pyproject.toml | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..506d291 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,64 @@ +[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" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] From e2d1f9506a9455ea78df20a268e505a19282d2c5 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:19:02 +1000 Subject: [PATCH 04/10] fix: correct four invalid trait declarations * markercolor was 'ColorTrait' without parentheses, so it bound as a method rather than a trait and the colour picker was inert. Now ColorTrait("blue"). * yintcept/gradient were Range(0.0, 5.0, 10.0) -- Range takes (low, high, default), so the default sat outside its own bounds. The traits initialised to an invalid 10.0 and assigning any in-range value raised TraitError. Bounds widened to (0.0, 10.0) with a 5.0 default. * listoptions listed 'Option 2' twice, so the third entry was unreachable. * Both panel constructors accepted **kwargs and then called HasTraits.__init__(self) without them, silently discarding any trait values passed by the caller. ApplicationMain.__init__ assigned the marker defaults but never chained to HasTraits.__init__. Declaring the defaults on the traits themselves makes the override unnecessary, so it is removed rather than repaired. 'main' is now a declared trait on both panels instead of an undeclared attribute. --- pyguitemplate/app.py | 13 +++++-------- pyguitemplate/panels/first_panel.py | 15 +++++++++++---- pyguitemplate/panels/second_panel.py | 7 +++++-- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/pyguitemplate/app.py b/pyguitemplate/app.py index 36131e2..45fd071 100644 --- a/pyguitemplate/app.py +++ b/pyguitemplate/app.py @@ -35,9 +35,11 @@ class ApplicationMain(HasTraits): first_panel = Instance(FirstPanel) second_panel = Instance(SecondPanel) display = Instance(Figure) - markercolor = ColorTrait - markerstyle = Enum(["+", ",", "*", "s", "p", "d", "o"]) - markersize = Range(0, 10, 2) + # 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) left_panel = Tabbed( Group( @@ -113,8 +115,3 @@ def _markersize_changed(self): if hasattr(self, "display_points"): self.display_points.set_markersize(self.markersize) schedule_redraw(self.display) - - def __init__(self, **kwargs): - self.markercolor = "blue" - self.markersize = 2 - self.markerstyle = "o" diff --git a/pyguitemplate/panels/first_panel.py b/pyguitemplate/panels/first_panel.py index 157591f..0ba9005 100644 --- a/pyguitemplate/panels/first_panel.py +++ b/pyguitemplate/panels/first_panel.py @@ -5,6 +5,7 @@ import numpy as np from traits.api import ( + Any, Bool, Button, Directory, @@ -24,11 +25,14 @@ 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 2"]) + listoptions = Enum(["Option 1", "Option 2", "Option 3"]) stringopt = Str("Default string which can also be empty.") stringoptread = Str("Default string which can also be empty [read-only version].") masterpath = Directory(os.getcwd()) @@ -36,8 +40,9 @@ class FirstPanel(HasTraits): floatval = Float() changedcount = Int() plotbutton = Button("Plot Me!") - yintcept = Range(0.0, 5.0, 10.0) - gradient = Range(0.0, 5.0, 10.0) + # 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( @@ -110,5 +115,7 @@ def _floatval_changed(self): self.stringopt = "Hey, you changed the integer value!" def __init__(self, main, **kwargs): - HasTraits.__init__(self) + # 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 index f93d857..25614d5 100644 --- a/pyguitemplate/panels/second_panel.py +++ b/pyguitemplate/panels/second_panel.py @@ -1,6 +1,6 @@ """Example control panel: intentionally empty, ready for your own controls.""" -from traits.api import HasTraits +from traits.api import Any, HasTraits from traitsui.api import View __all__ = ["SecondPanel"] @@ -8,8 +8,11 @@ class SecondPanel(HasTraits): + #: The ApplicationMain instance that owns this panel. + main = Any() + view = View() def __init__(self, main, **kwargs): - HasTraits.__init__(self) + super().__init__(**kwargs) self.main = main From b6435e861683438d89a6b8477614dc4931841d99 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:22:51 +1000 Subject: [PATCH 05/10] fix: make the marker colour/style/size controls actually do something All three handlers were guarded by 'hasattr(self, "display_points")', but nothing in the codebase ever assigned display_points. The guard was therefore always false and the entire marker control group was inert. Plot state now lives on ApplicationMain: panels call main.plot(x, y), which records the Line2D it created, and apply_marker_style() pushes the three traits onto it. The handlers delegate to that. Fixing the guard alone would not have been enough. ColorTrait yields a toolkit-native QColor/wx.Colour and Matplotlib raises ValueError on both, so the old 'color=self.main.markercolor' plotting calls in the panel could not have worked either. pyguitemplate.colors.to_mpl_color() converts them. Also collapses _gradient_changed and _yintcept_changed, which were byte-identical, into replot(), and gives plotbutton the _plotbutton_fired handler it was missing -- pressing it did nothing. --- pyguitemplate/app.py | 55 +++++++++++++++++++++++------ pyguitemplate/colors.py | 52 +++++++++++++++++++++++++++ pyguitemplate/panels/first_panel.py | 42 ++++++---------------- 3 files changed, 106 insertions(+), 43 deletions(-) create mode 100644 pyguitemplate/colors.py diff --git a/pyguitemplate/app.py b/pyguitemplate/app.py index 45fd071..8ce9795 100644 --- a/pyguitemplate/app.py +++ b/pyguitemplate/app.py @@ -8,7 +8,7 @@ from matplotlib.figure import Figure from mayavi.core.ui.mayavi_scene import MayaviScene from mayavi.tools.mlab_scene_model import MlabSceneModel -from traits.api import Enum, HasTraits, Instance, Range +from traits.api import Any, Enum, HasTraits, Instance, Range from traitsui.api import ( ColorTrait, Group, @@ -21,6 +21,7 @@ ) from tvtk.pyface.scene_editor import SceneEditor +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 @@ -41,6 +42,10 @@ class ApplicationMain(HasTraits): 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( @@ -100,18 +105,46 @@ def _first_panel_default(self): def _second_panel_default(self): return SecondPanel(self) - def _markercolor_changed(self): - if hasattr(self, "display_points"): - self.display_points.set_color(self.markercolor) - self.display_points.set_markeredgecolor(self.markercolor) + 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): - if hasattr(self, "display_points"): - self.display_points.set_marker(self.markerstyle) - schedule_redraw(self.display) + self.apply_marker_style() def _markersize_changed(self): - if hasattr(self, "display_points"): - self.display_points.set_markersize(self.markersize) - schedule_redraw(self.display) + 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/panels/first_panel.py b/pyguitemplate/panels/first_panel.py index 0ba9005..65fe9db 100644 --- a/pyguitemplate/panels/first_panel.py +++ b/pyguitemplate/panels/first_panel.py @@ -18,8 +18,6 @@ ) from traitsui.api import Group, Item, View -from pyguitemplate.mpl_editor import schedule_redraw - __all__ = ["FirstPanel"] @@ -70,39 +68,19 @@ class FirstPanel(HasTraits): ), ) + 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): - y = self.gradient * np.array(range(10)) + self.yintcept - - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - ax.plot( - np.array(range(10)), - y, - color=self.main.markercolor, - marker=self.main.markerstyle, - markersize=self.main.markersize, - markeredgewidth=0.0, - linestyle="-", - ) - schedule_redraw(figure) + self.replot() def _yintcept_changed(self): - y = self.gradient * np.array(range(10)) + self.yintcept - - figure = self.main.display - figure.clear() - ax = figure.add_subplot(111) - ax.plot( - np.array(range(10)), - y, - color=self.main.markercolor, - marker=self.main.markerstyle, - markersize=self.main.markersize, - markeredgewidth=0.0, - linestyle="-", - ) - schedule_redraw(figure) + self.replot() + + def _plotbutton_fired(self): + self.replot() def _rangex_changed(self): self.yval = 1.0 / self.rangex From 4f1e248c4b352874482ebec9dbc7a40aaaae7594 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:24:04 +1000 Subject: [PATCH 06/10] feat: run without Mayavi instead of failing to import app.py imported mayavi and tvtk at module scope, so a missing VTK made the entire application unimportable -- not just the 3D tab. VTK is the largest and most fragile dependency here and plenty of users of this template never touch the 3D view. The scene tab moves to its own module that probes for Mayavi once. When it is absent the tab becomes a placeholder carrying install instructions, and both variants expose the same plot_demo() so callers never branch on availability. When it is present the tab now renders a demo surface rather than opening blank. --- pyguitemplate/app.py | 16 ++++----- pyguitemplate/scene.py | 78 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 pyguitemplate/scene.py diff --git a/pyguitemplate/app.py b/pyguitemplate/app.py index 8ce9795..6e7d661 100644 --- a/pyguitemplate/app.py +++ b/pyguitemplate/app.py @@ -6,8 +6,6 @@ """ from matplotlib.figure import Figure -from mayavi.core.ui.mayavi_scene import MayaviScene -from mayavi.tools.mlab_scene_model import MlabSceneModel from traits.api import Any, Enum, HasTraits, Instance, Range from traitsui.api import ( ColorTrait, @@ -19,19 +17,20 @@ VGroup, View, ) -from tvtk.pyface.scene_editor import SceneEditor 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): - scene = Instance(MlabSceneModel, ()) + #: Either a live Mayavi scene or an install-hint placeholder. + scene_tab = Instance(HasTraits) first_panel = Instance(FirstPanel) second_panel = Instance(SecondPanel) @@ -63,11 +62,7 @@ class ApplicationMain(HasTraits): ), label="Display", ), - Item( - name="scene", - label="Mayavi", - editor=SceneEditor(scene_class=MayaviScene), - ), + Item(name="scene_tab", label="Mayavi", style="custom", resizable=True), show_labels=False, ) @@ -97,6 +92,9 @@ def _display_default(self): 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. 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() From dc401a77387796fc635500795ebcb11b3359fe93 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:25:31 +1000 Subject: [PATCH 07/10] feat: turn the empty second panel into a worked example SecondPanel declared 'view = View()' and nothing else, so the second tab rendered blank -- a template tab that demonstrated nothing. It is now a small signal generator: pick a waveform, set amplitude, frequency, noise and sample count, and it derives the series and pushes it to the shared display. That covers ground the first panel does not -- combining several traits into a derived result, @observe on a group of traits, enabled_when driven by another control, and writing to a user-chosen file with the failure path handled. --- pyguitemplate/panels/second_panel.py | 111 ++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/pyguitemplate/panels/second_panel.py b/pyguitemplate/panels/second_panel.py index 25614d5..bf0b18b 100644 --- a/pyguitemplate/panels/second_panel.py +++ b/pyguitemplate/panels/second_panel.py @@ -1,17 +1,120 @@ -"""Example control panel: intentionally empty, ready for your own controls.""" +"""Example control panel: a small signal generator. -from traits.api import Any, HasTraits -from traitsui.api import View +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, Int, 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() - view = View() + 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) From bc68d14da02204970462e16658532de2d735f409 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:29:21 +1000 Subject: [PATCH 08/10] test: add a headless pytest suite The project had no tests at all. 44 tests covering the display, both panels, the colour conversion and the optional scene tab, with a named regression test for each bug this branch fixes. Nothing opens a window -- the tests build the trait objects and inspect the Matplotlib figure -- so they run on CI with no display. Verified by reintroducing the original bugs, which fails 6 of them. --- tests/conftest.py | 19 +++++++ tests/test_app.py | 82 ++++++++++++++++++++++++++++++ tests/test_colors.py | 62 +++++++++++++++++++++++ tests/test_first_panel.py | 72 ++++++++++++++++++++++++++ tests/test_scene_and_editor.py | 33 ++++++++++++ tests/test_second_panel.py | 92 ++++++++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py create mode 100644 tests/test_colors.py create mode 100644 tests/test_first_panel.py create mode 100644 tests/test_scene_and_editor.py create mode 100644 tests/test_second_panel.py 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") From a45b4e75fb61aa2b0be3f82af1bb6e65717695ce Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:32:38 +1000 Subject: [PATCH 09/10] ci: add GitHub Actions running lint, format and tests Runs on Python 3.9, 3.11 and 3.12 against the Qt extra, using the offscreen platform plugin so no display server is needed. Also applies ruff's formatting to the existing sources so the format check starts green, and excludes Markdown from ruff so README snippets are left as written. --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++ pyguitemplate/app.py | 1 - pyguitemplate/mpl_editor.py | 3 +- pyguitemplate/panels/first_panel.py | 1 - pyguitemplate/panels/second_panel.py | 13 ++++++-- pyproject.toml | 3 ++ 6 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml 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/pyguitemplate/app.py b/pyguitemplate/app.py index 6e7d661..fd860e3 100644 --- a/pyguitemplate/app.py +++ b/pyguitemplate/app.py @@ -28,7 +28,6 @@ class ApplicationMain(HasTraits): - #: Either a live Mayavi scene or an install-hint placeholder. scene_tab = Instance(HasTraits) diff --git a/pyguitemplate/mpl_editor.py b/pyguitemplate/mpl_editor.py index 879d7cd..c0ba12e 100644 --- a/pyguitemplate/mpl_editor.py +++ b/pyguitemplate/mpl_editor.py @@ -52,13 +52,12 @@ def _create_canvas(parent, figure): return panel else: - from traitsui.wx.editor import Editor - 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.""" diff --git a/pyguitemplate/panels/first_panel.py b/pyguitemplate/panels/first_panel.py index 65fe9db..e3bb367 100644 --- a/pyguitemplate/panels/first_panel.py +++ b/pyguitemplate/panels/first_panel.py @@ -22,7 +22,6 @@ class FirstPanel(HasTraits): - #: The ApplicationMain instance that owns this panel. main = Any() diff --git a/pyguitemplate/panels/second_panel.py b/pyguitemplate/panels/second_panel.py index bf0b18b..04a3ee8 100644 --- a/pyguitemplate/panels/second_panel.py +++ b/pyguitemplate/panels/second_panel.py @@ -7,7 +7,17 @@ """ import numpy as np -from traits.api import Any, Bool, Button, Enum, File, HasTraits, Int, Range, Str, observe +from traits.api import ( + Any, + Bool, + Button, + Enum, + File, + HasTraits, + Range, + Str, + observe, +) from traitsui.api import Group, HGroup, Item, View __all__ = ["SecondPanel"] @@ -16,7 +26,6 @@ class SecondPanel(HasTraits): - #: The ApplicationMain instance that owns this panel. main = Any() diff --git a/pyproject.toml b/pyproject.toml index 506d291..326d391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ 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"] From 078b520050909ea8ee004db4f708148953f7d2b8 Mon Sep 17 00:00:00 2001 From: Brendan Griffen Date: Mon, 27 Jul 2026 22:36:47 +1000 Subject: [PATCH 10/10] docs: rewrite the README for the current codebase The install instructions were the main problem: they led with Enthought's EPD distribution, which has been discontinued for years and was described as needing a 32-bit build and an academic licence. The rest of the page documented editing Common.py by hand to switch import styles -- a file that no longer exists. Replaced with real installation via the toolkit extras, a project layout map, a worked 'add your own panel' example, a troubleshooting table and testing instructions. The example panel is executed as part of preparing this change, so it is known to run. Screenshots are kept but labelled as predating the current version. --- README.md | 217 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 191 insertions(+), 26 deletions(-) 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).