1. Overview
Building custom widgets allows one to use the Qt Designer editor to place a custom widget rather than doing it manually in a handler file.
A useful custom widgets would be a great way to contribute back to LinuxCNC.
1.1. Widgets
Widget is the general name for the UI objects such as buttons and labels in PyQt.
There are also special widgets made for LinuxCNC that make integration easier.
All these widgets can be placed with Qt Designer editor - allowing one to see the result before actually loading the panel in LinuxCNC.
1.2. Qt Designer
Qt Designer is a WYSIWYG (What You See is What You Get) editor for placing PyQt widgets.
It’s original intend was for building the graphic widgets for programs.
We leverage it to build screens and panels for LinuxCNC.
In Qt Designer, on the left side of the editor, you find three categories of LinuxCNC widgets:
-
HAL only widgets.
-
LinuxCNC controller widgets.
-
dialog widgets.
For Qt Designer to add custom widgets to it’s editor it must have a plugin added to the right folder.
1.3. Initialization Process
QtVCP does extra setup for widgets subclassed from _HALWidgetBase
, aka "HAL-ified" widgets.
This includes:
-
Injecting important variables,
-
Calling an extra setup function
-
Calling a closing cleanup function at shutdown.
These functions are not called when the Qt Designer editor displays the widgets.
When QtVCP builds a screen from the .ui
file:
-
It searches for all the HAL-ified widgets.
-
It finds the
ScreenOptions
widget, to collect information it needs to inject into the other widgets -
It instantiates each widget and if it is a HAL-ified widget, calls the
hal_init()
function.
hal_init()
is defined in the base class and it:-
Adds variables such as the preference file to every HAL-ified widget.
-
Call
+_hal_init()+
on the widget.
+_hal_init()+
allows the widget designer to do setup that requires access to the extra variables.
-
Here is a description of the extra variables injected into "HAL-ified" widgets:
-
self.HAL_GCOMP
-
The HAL component instance
-
self.HAL_NAME
-
This widget’s name as a string
-
self.QT_OBJECT_
-
This widget’s object instance
-
self.QTVCP_INSTANCE_
-
The very top level parent of the screen
-
self.PATHS_
-
The QtVCP’s path library instance
-
self.PREFS_
-
The optional preference file instance
-
self.SETTINGS_
-
The
Qsettings
object instance
1.4. cleanup process
When QtVCP closes, it calls the +_hal_cleanup()+
function on all HAL-ified widgets.
The base class creates an empty +_hal_cleanup()+
function, which can be redefined in the custom widget subclass.
This can be used to do such things as record preferences, etc.
This function is not called when the Qt Designer editor displays the widgets.
2. Custom HAL Widgets
HAL widgets are the simplest to show example of.
qtvcp/widgets/simple_widgets.py
holds many HAL only widgets.
Lets look at a snippet of simple_widgets.py
:
This is where we import libraries that our widget class needs.
#!/usr/bin/env python3 ############################### # Imports ############################### from PyQt5 import QtWidgets # <1> from qtvcp.widgets.widget_baseclass \ import _HalWidgetBase, _HalSensitiveBase # <2> import hal # <3>
In this case we need access to:
-
PyQt’s QtWidgets library,
-
LinuxCNC’s HAL library, and
-
QtVCP’s widget
baseclass
's_HalSensitiveBase
for automatic HAL pin setup and to disable/enable the widget (also known as input sensitivity).
There is also_HalToggleBase
, and_HalScaleBase
functions available in the library._HalToggleBase
, and_HalScaleBase
.
Here is a custom widget based on PyQt’s QGridLayout
widget.
QGridLayout
allows one to:
-
Place objects in a grid fashion.
-
Enable/disable all widgets inside it based on a HAL pin state.
###################### # WIDGET ###################### class Lcnc_GridLayout(QtWidgets.QWidget, _HalSensitiveBase): # <1> def __init__(self, parent = None): # <2> super(GridLayout, self).__init__(parent) # <3>
Line by Line:
-
This defines the class name and the libraries it inherits from.
This class, namedLcnc_GridLayout
, inherits the functions ofQWidget
and+_HalSensitiveBase+
.
+_HalSensitiveBase+
is subclass of+_HalWidgetBase+
, the base class of most QtVCP widgets, meaning it has all the functions of+_HalWidgetBase+
plus the functions of+_HalSensitiveBase+
.
It adds the function to make the widget be enabled or disabled based on a HAL input BIT pin. -
This is the function called when the widget is first made (said instantiated) - this is pretty standard.
-
This function initializes our widget’s
Super
classes.
Super
just means the inherited baseclasses, that isQWidget
and_HalSensitiveBase
.
Pretty standard other than the widget name will change.
3. Custom Controller Widgets Using STATUS
Widget that interact with LinuxCNC’s controller are only a little more complicated and they require some extra libraries.
In this cut down example we will add properties that can be changed in Qt Designer.
This LED indicator widget will respond to selectable LinuxCNC controller states.
#!/usr/bin/env python3 ############################### # Imports ############################### from PyQt5.QtCore import pyqtProperty from qtvcp.widgets.led_widget import LED from qtvcp.core import Status ########################################### # **** instantiate libraries section **** # ########################################### STATUS = Status() ########################################## # custom widget class definition ########################################## class StateLED(LED): def __init__(self, parent=None): super(StateLED, self).__init__(parent) self.has_hal_pins = False self.setState(False) self.is_estopped = False self.is_on = False self.invert_state = False def _hal_init(self): if self.is_estopped: STATUS.connect('state-estop', lambda w:self._flip_state(True)) STATUS.connect('state-estop-reset', lambda w:self._flip_state(False)) elif self.is_on: STATUS.connect('state-on', lambda w:self._flip_state(True)) STATUS.connect('state-off', lambda w:self._flip_state(False)) def _flip_state(self, data): if self.invert_state: data = not data self.change_state(data) ######################################################################### # Qt Designer properties setter/getters/resetters ######################################################################## # invert status def set_invert_state(self, data): self.invert_state = data def get_invert_state(self): return self.invert_state def reset_invert_state(self): self.invert_state = False # machine is estopped status def set_is_estopped(self, data): self.is_estopped = data def get_is_estopped(self): return self.is_estopped def reset_is_estopped(self): self.is_estopped = False # machine is on status def set_is_on(self, data): self.is_on = data def get_is_on(self): return self.is_on def reset_is_on(self): self.is_on = False ####################################### # Qt Designer properties ####################################### invert_state_status = pyqtProperty(bool, get_invert_state, set_invert_state, reset_invert_state) is_estopped_status = pyqtProperty(bool, get_is_estopped, set_is_estopped, reset_is_estopped) is_on_status = pyqtProperty(bool, get_is_on, set_is_on, reset_is_on)
3.1. In The Imports Section
This is where we import libraries that our widget class needs.
#!/usr/bin/env python3 ############################### # Imports ############################### from PyQt5.QtCore import pyqtProperty # <1> from qtvcp.widgets.led_widget import LED # <2> from qtvcp.core import Status # <3>
We import
-
pyqtProperty
so we can interact with the Qt Designer editor, -
LED
because our custom widget is based on it, -
Status
because it gives us status messages from LinuxCNC.
3.2. In The Instantiate Libraries Section
Here we create the Status
library instance:
########################################### # **** instantiate libraries section **** # ########################################### STATUS = Status()
Typically we instantiated the library outside of the widget class so that the reference to it is global - meaning you don’t need to use self.
in front of it.
By convention we use all capital letters in the name for global references.
3.3. In The Custom Widget Class Definition Section
This is the meat and potatoes of our custom widget.
class StateLed(LED): # <1> def __init__(self, parent=None): # <2> super(StateLed, self).__init__(parent) # <3> self.has_hal_pins = False # <4> self.setState(False) # <5> self.is_estopped = False self.is_on = False self.invert_state = False
-
Defines the name of our custom widget and what other class it inherits from.
In this case we inheritLED
- a QtVCP widget that represents a status light. -
Typical of most widgets - called when the widget is first made.
-
Typical of most widgets - calls the parent (super) widget initialization code.
Then we set some attributes:
-
Inherited from
Lcnc_Led
- we set it here so no HAL pin is made. -
Inherited from
Lcnc_led
- we set it to make sure the LED is off.
The other attributes are for the selectable options of our widget.
def _hal_init(self): if self.is_estopped: STATUS.connect('state-estop', lambda w:self._flip_state(True)) STATUS.connect('state-estop-reset', lambda w:self._flip_state(False)) elif self.is_on: STATUS.connect('state-on', lambda w:self._flip_state(True)) STATUS.connect('state-off', lambda w:self._flip_state(False))
This function connects STATUS
(LinuxCNC status message library) to our widget, so that the LED will on or off based on the selected state of the controller.
We have two states we can choose from is_estopped
or is_on
.
Depending on which is active our widget get connected to the appropriate STATUS messages.
+_hal_init()+
is called on each widget that inherits +_HalWidgetBase+
, when QtVCP first builds the screen.
You might wonder why it’s called on this widget since we didn’t have +_HalWidgetBase+
in our class definition (class Lcnc_State_Led(Lcnc_Led):
) - it’s called because Lcnc_Led
inherits +_HalWidgetBase+
.
In this function you have access to some extra information (though we don’t use them in this example):
-
self.HAL_GCOMP
-
the HAL component instance
-
self.HAL_NAME
-
This widget’s name as a string
-
self.QT_OBJECT_
-
This widget’s PyQt object instance
-
self.QTVCP_INSTANCE_
-
The very top level parent of the screen
-
self.PATHS_
-
The instance of QtVCP’s path library
-
self.PREFS_
-
the instance of an optional preference file
-
self.SETTINGS_
-
the
Qsettings
object
We could use this information to create HAL pins or look up image paths etc.
STATUS.connect('state-estop', lambda w:self._flip_state(True))
Lets look at this line more closely:
-
STATUS
is very common theme is widget building.
STATUS
usesGObject
message system to send messages to widgets that register to it.
This line is the registering process. -
state-estop
is the message we wish to listen for and act on. There are many messages available. -
lambda w:self._flip_state(True)
is what happens when the message is caught.
The lambda function accepts the widget instance (w
) that GObject sends it and then calls the functionself._flip_state(True)
.
Lambda was used to strip the (w
) object before calling theself._flip_state
function.
It also allowed use to sendself._flip_state()
the True state.
def _flip_state(self, data): if self.invert_state: data = not data self.change_state(data)
This is the function that actually flips the state of the LED.
It is what gets called when the appropriate STATUS message is accepted.
STATUS.connect('current-feed-rate', self._set_feedrate_text)
The function called looks like this:
def _set_feedrate_text(self, widget, data):
in which the widget and any data must be accepted by the function.
3.3.1. In the Designer Properties Setter/Getters/Resetters Section
######################################################################### # Qt Designer properties setter/getters/resetters ######################################################################## # invert status def set_invert_state(self, data): self.invert_state = data def get_invert_state(self): return self.invert_state def reset_invert_state(self): self.invert_state = False # machine is estopped status def set_is_estopped(self, data): self.is_estopped = data def get_is_estopped(self): return self.is_estopped def reset_is_estopped(self): self.is_estopped = False # machine is on status def set_is_on(self, data): self.is_on = data def get_is_on(self): return self.is_on def reset_is_on(self): self.is_on = False
This is how Qt Designer sets the attributes of the widget.
This can also be called directly in the widget.
3.3.2. In the Designer properties section
####################################### # Qt Designer properties ####################################### invert_state_status = pyqtProperty(bool, get_invert_state, set_invert_state, reset_invert_state) is_estopped_status = pyqtProperty(bool, get_is_estopped, set_is_estopped, reset_is_estopped) is_on_status = pyqtProperty(bool, get_is_on, set_is_on, reset_is_on)
This is the registering of properties in Qt Designer.
The property name:
-
is the text used in Qt Designer,
-
cannot be the same as the attributes they represent.
These properties show in Qt Designer in the order they appear here.
4. Custom Controller Widgets with Actions
Here is an example of a widget that sets the user reference system.
It changes:
-
the machine controller state using the
ACTION
library, -
whether the button can be clicked or not using the
STATUS
library.
import os import hal from PyQt5.QtWidgets import QWidget, QToolButton, QMenu, QAction from PyQt5.QtCore import Qt, QEvent, pyqtProperty, QBasicTimer, pyqtSignal from PyQt5.QtGui import QIcon from qtvcp.widgets.widget_baseclass import _HalWidgetBase from qtvcp.widgets.dialog_widget import EntryDialog from qtvcp.core import Status, Action, Info # Instantiate the libraries with global reference # STATUS gives us status messages from LinuxCNC # INFO holds INI details # ACTION gives commands to LinuxCNC STATUS = Status() INFO = Info() ACTION = Action() class SystemToolButton(QToolButton, _HalWidgetBase): def __init__(self, parent=None): super(SystemToolButton, self).__init__(parent) self._joint = 0 self._last = 0 self._block_signal = False self._auto_label_flag = True SettingMenu = QMenu() for system in('G54', 'G55', 'G56', 'G57', 'G58', 'G59', 'G59.1', 'G59.2', 'G59.3'): Button = QAction(QIcon('exit24.png'), system, self) Button.triggered.connect(self[system.replace('.','_')]) SettingMenu.addAction(Button) self.setMenu(SettingMenu) self.dialog = EntryDialog() def _hal_init(self): if not self.text() == '': self._auto_label_flag = False def homed_on_test(): return (STATUS.machine_is_on() and (STATUS.is_all_homed() or INFO.NO_HOME_REQUIRED)) STATUS.connect('state-off', lambda w: self.setEnabled(False)) STATUS.connect('state-estop', lambda w: self.setEnabled(False)) STATUS.connect('interp-idle', lambda w: self.setEnabled(homed_on_test())) STATUS.connect('interp-run', lambda w: self.setEnabled(False)) STATUS.connect('all-homed', lambda w: self.setEnabled(True)) STATUS.connect('not-all-homed', lambda w, data: self.setEnabled(False)) STATUS.connect('interp-paused', lambda w: self.setEnabled(True)) STATUS.connect('user-system-changed', self._set_user_system_text) def G54(self): ACTION.SET_USER_SYSTEM('54') def G55(self): ACTION.SET_USER_SYSTEM('55') def G56(self): ACTION.SET_USER_SYSTEM('56') def G57(self): ACTION.SET_USER_SYSTEM('57') def G58(self): ACTION.SET_USER_SYSTEM('58') def G59(self): ACTION.SET_USER_SYSTEM('59') def G59_1(self): ACTION.SET_USER_SYSTEM('59.1') def G59_2(self): ACTION.SET_USER_SYSTEM('59.2') def G59_3(self): ACTION.SET_USER_SYSTEM('59.3') def _set_user_system_text(self, w, data): convert = { 1:"G54", 2:"G55", 3:"G56", 4:"G57", 5:"G58", 6:"G59", 7:"G59.1", 8:"G59.2", 9:"G59.3"} if self._auto_label_flag: self.setText(convert[int(data)]) def ChangeState(self, joint): if int(joint) != self._joint: self._block_signal = True self.setChecked(False) self._block_signal = False self.hal_pin.set(False) ############################## # required class boiler code # ############################## def __getitem__(self, item): return getattr(self, item) def __setitem__(self, item, value): return setattr(self, item, value)
5. Stylesheet Property Changes Based On Events
It’s possible to have widgets restyled when events change.
You must explicitly "polish" the widget to have PyQt redo the style.
This is a relatively expensive function so should be used sparingly.
This example sets an isHomed
property based on LinuxCNC’s homed state and in turn uses it to change stylesheet properties:
class HomeLabel(QLabel, _HalWidgetBase): def __init__(self, parent=None): super(HomeLabel, self).__init__(parent) self.joint_number = 0 # for stylesheet reading self._isHomed = False def _hal_init(self): super(HomeLabel, self)._hal_init() STATUS.connect('homed', lambda w,d: self._home_status_polish(int(d), True)) STATUS.connect('unhomed', lambda w,d: self._home_status_polish(int(d), False)) # update ishomed property # polish widget so stylesheet sees the property change # some stylesheets color the text on home/unhome def _home_status_polish(self, d, state): if self.joint_number = d: self.setProperty('isHomed', state) self.style().unpolish(self) self.style().polish(self) # Qproperty getter and setter def getisHomed(self): return self._isHomed def setisHomed(self, data): self._isHomed = data # Qproperty isHomed = QtCore.pyqtProperty(bool, getisHomed, setisHomed)
Here is a sample stylesheet to change text color based on home state.
In this case any widget based on the HomeLabel widget above will change text color.
You would usually pick specific widgets using HomeLabel #specific_widget_name[homed=true]
:
HomeLabel[homed=true] {
color: green;
}
HomeLabel[homed=false] {
color: red;
}
6. Use Stylesheets To Change Custom Widget Properties
class Label(QLabel): def __init__(self, parent=None): super(Label, self).__init__(parent) alternateFont0 = self.font # Qproperty getter and setter def getFont0(self): return self.aleternateFont0 def setFont0(self, value): self.alternateFont0(value) # Qproperty styleFont0 = pyqtProperty(QFont, getFont0, setFont0)
Sample stylesheet that sets a custom widget property.
Label{
qproperty-styleFont0: "Times,12,-1,0,90,0,0,0,0,0";
}
7. Widget Plugins
We must register our custom widget for Qt Designer to use them.
Here are a typical samples.
They would need to be added to qtvcp/plugins/
Then qtvcp/plugins/qtvcp_plugin.py
would need to be adjusted to import them.
7.1. Gridlayout Example
#!/usr/bin/env python3 from PyQt5 import QtCore, QtGui from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin from qtvcp.widgets.simple_widgets import Lcnc_GridLayout from qtvcp.widgets.qtvcp_icons import Icon ICON = Icon() #################################### # GridLayout #################################### class LcncGridLayoutPlugin(QPyDesignerCustomWidgetPlugin): def __init__(self, parent = None): QPyDesignerCustomWidgetPlugin.__init__(self) self.initialized = False def initialize(self, formEditor): if self.initialized: return self.initialized = True def isInitialized(self): return self.initialized def createWidget(self, parent): return Lcnc_GridLayout(parent) def name(self): return "Lcnc_GridLayout" def group(self): return "LinuxCNC - HAL" def icon(self): return QtGui.QIcon(QtGui.QPixmap(ICON.get_path('lcnc_gridlayout'))) def toolTip(self): return "HAL enable/disable GridLayout widget" def whatsThis(self): return "" def isContainer(self): return True def domXml(self): return '<widget class="Lcnc_GridLayout" name="lcnc_gridlayout" />\n' def includeFile(self): return "qtvcp.widgets.simple_widgets"
7.2. SystemToolbutton Example
#!/usr/bin/env python3 from PyQt5 import QtCore, QtGui from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin from qtvcp.widgets.system_tool_button import SystemToolButton from qtvcp.widgets.qtvcp_icons import Icon ICON = Icon() #################################### # SystemToolButton #################################### class SystemToolButtonPlugin(QPyDesignerCustomWidgetPlugin): def __init__(self, parent = None): super(SystemToolButtonPlugin, self).__init__(parent) self.initialized = False def initialize(self, formEditor): if self.initialized: return self.initialized = True def isInitialized(self): return self.initialized def createWidget(self, parent): return SystemToolButton(parent) def name(self): return "SystemToolButton" def group(self): return "LinuxCNC - Controller" def icon(self): return QtGui.QIcon(QtGui.QPixmap(ICON.get_path('systemtoolbutton'))) def toolTip(self): return "Button for selecting a User Coordinate System" def whatsThis(self): return "" def isContainer(self): return False def domXml(self): return '<widget class="SystemToolButton" name="systemtoolbutton" />\n' def includeFile(self): return "qtvcp.widgets.system_tool_button"
7.3. Making a plugin with a MenuEntry dialog box
It possible to add an entry to the dialog that pops up when you right click the widget in the layout.
This can do things such as selecting options in a more convenient way.
This is the plugin used for action buttons.
#!/usr/bin/env python3 import sip from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin, \ QPyDesignerTaskMenuExtension, QExtensionFactory, \ QDesignerFormWindowInterface, QPyDesignerMemberSheetExtension from qtvcp.widgets.action_button import ActionButton from qtvcp.widgets.qtvcp_icons import Icon ICON = Icon() Q_TYPEID = { 'QDesignerContainerExtension': 'org.qt-project.Qt.Designer.Container', 'QDesignerPropertySheetExtension': 'org.qt-project.Qt.Designer.PropertySheet', 'QDesignerTaskMenuExtension': 'org.qt-project.Qt.Designer.TaskMenu', 'QDesignerMemberSheetExtension': 'org.qt-project.Qt.Designer.MemberSheet' } #################################### # ActionBUTTON #################################### class ActionButtonPlugin(QPyDesignerCustomWidgetPlugin): # The __init__() method is only used to set up the plugin and define its # initialized variable. def __init__(self, parent=None): super(ActionButtonPlugin, self).__init__(parent) self.initialized = False # The initialize() and isInitialized() methods allow the plugin to set up # any required resources, ensuring that this can only happen once for each # plugin. def initialize(self, formEditor): if self.initialized: return manager = formEditor.extensionManager() if manager: self.factory = ActionButtonTaskMenuFactory(manager) manager.registerExtensions(self.factory, Q_TYPEID['QDesignerTaskMenuExtension']) self.initialized = True def isInitialized(self): return self.initialized # This factory method creates new instances of our custom widget def createWidget(self, parent): return ActionButton(parent) # This method returns the name of the custom widget class def name(self): return "ActionButton" # Returns the name of the group in Qt Designer's widget box def group(self): return "LinuxCNC - Controller" # Returns the icon def icon(self): return QtGui.QIcon(QtGui.QPixmap(ICON.get_path('actionbutton'))) # Returns a tool tip short description def toolTip(self): return "Action button widget" # Returns a short description of the custom widget for use in a "What's # This?" help message for the widget. def whatsThis(self): return "" # Returns True if the custom widget acts as a container for other widgets; def isContainer(self): return False # Returns an XML description of a custom widget instance that describes # default values for its properties. def domXml(self): return '<widget class="ActionButton" name="actionbutton" />\n' # Returns the module containing the custom widget class. It may include # a module path. def includeFile(self): return "qtvcp.widgets.action_button" class ActionButtonDialog(QtWidgets.QDialog): def __init__(self, widget, parent = None): QtWidgets.QDialog.__init__(self, parent) self.widget = widget self.previewWidget = ActionButton() buttonBox = QtWidgets.QDialogButtonBox() okButton = buttonBox.addButton(buttonBox.Ok) cancelButton = buttonBox.addButton(buttonBox.Cancel) okButton.clicked.connect(self.updateWidget) cancelButton.clicked.connect(self.reject) layout = QtWidgets.QGridLayout() self.c_estop = QtWidgets.QCheckBox("Estop Action") self.c_estop.setChecked(widget.estop ) layout.addWidget(self.c_estop) layout.addWidget(buttonBox, 5, 0, 1, 2) self.setLayout(layout) self.setWindowTitle(self.tr("Set Options")) def updateWidget(self): formWindow = QDesignerFormWindowInterface.findFormWindow(self.widget) if formWindow: formWindow.cursor().setProperty("estop_action", QtCore.QVariant(self.c_estop.isChecked())) self.accept() class ActionButtonMenuEntry(QPyDesignerTaskMenuExtension): def __init__(self, widget, parent): super(QPyDesignerTaskMenuExtension, self).__init__(parent) self.widget = widget self.editStateAction = QtWidgets.QAction( self.tr("Set Options..."), self) self.editStateAction.triggered.connect(self.updateOptions) def preferredEditAction(self): return self.editStateAction def taskActions(self): return [self.editStateAction] def updateOptions(self): dialog = ActionButtonDialog(self.widget) dialog.exec_() class ActionButtonTaskMenuFactory(QExtensionFactory): def __init__(self, parent = None): QExtensionFactory.__init__(self, parent) def createExtension(self, obj, iid, parent): if not isinstance(obj, ActionButton): return None if iid == Q_TYPEID['QDesignerTaskMenuExtension']: return ActionButtonMenuEntry(obj, parent) elif iid == Q_TYPEID['QDesignerMemberSheetExtension']: return ActionButtonMemberSheet(obj, parent) return None