1. Overview

Building custom widgets allows one to use the Qt Designer editor to place
a custom widget rather then 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.
This widgets can be placed with qt Designer editor - allowing one to see the result
before actually loading the panel in linuxcnc.

1.2. Designer

Qt Designer is a 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 linuxcnc widgets are split in three heading on the left side of the editor.
One is for HAL only widgets.
One is for Linuxcnc controller widgets
And one is for dialog widgets.

For Designer to add custom widgets to it’s editor it must have a plugin added to the right folder.

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.

#!/usr/bin/env python3

###############################
# Imports
###############################
from PyQt5 import QtWidgets
from qtvcp.widgets.widget_baseclass import _HalWidgetBase, _HalSensitiveBase
import hal

######################
# WIDGET
######################

class Lcnc_GridLayout(QtWidgets.QWidget, _HalSensitiveBase):
    def __init__(self, parent = None):
        super(GridLayout, self).__init__(parent)

2.1. In the Imports section

This is where we import libraries that our widget class needs.
In this case we need access to pyqt’s QtWidgets library, linuxcnc’s hal library
and qtvcp’s widget baseclass _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.

2.2. In the WIDGET section

Ok here is our custom widget based on pyQT’s QGridLayout widget.
grid layout allows one to place objects in a grid fashion.
But this grid_layout also will enable and disable all widgets inside it
based on the state of a HAL pin.
Line by Line:

class Lcnc_GridLayout(QtWidgets.QWidget, _HalSensitiveBase):

This defines the class name and the libraries in inherits from.
this class named Lcnc_GridLayout inheriets the functions of QWidget 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.

def __init__(self, parent = None):

This is the function called when the widget is first made (said instantiated)- this is pretty standard.

super(GridLayout, self).__init__(parent)

This function initializes our widget’s Super classes.
Super just means the inherited baseclasses; QWidget and _HalSensitiveBase
Pretty standard other the the widget name will change

3. Custom Controller Widgets using STATUS

Widget that interact with linuxcnc’s controller are only a little more complicated
they require some extra libraries.
In this cut down example we will add properties that can be changed in 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)

    #########################################################################
    # 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

    #######################################
    # 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.
We import pyqtProperty so we can interact with the Designer editor.
we import LED because our custom widget is based on it.
We import Status because it gives us status messages from linuxcnc.

3.2. In the Instantiate Libraries section

Typically we instantiated the libraries 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.

3.3. In the custom widget class definition section

This is the meat and potatoes of our custom widget.

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

This defines the name of our custom widget and what other class it inherits from, in this case
we inherit LED - a Qtvcp widget that represents a status light.
The init is typical of most widgets, it is called when the widget is first made.
the super line is typical of most widgets - it calls the parent (super) widget’s initialization code.
then we set some attributes.
self.has_hal_pins is an attribute inherited from Lcnc_Led - we set it here so no HAL Pins are made.
self.setState is 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_int() is called on each widget that inherited _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 widgets name as a string
        self.QT_OBJECT_ = This widgets pyQt object instance
        self.QTVCP_INSTANCE_ = The very toplevel Parent Of the screen
        self.PATHS_ = The instance of Qtvcp's path library
        self.PREFS_ = the isnstance of an optional preference file

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 use GObject message system to send messages to widgets that register to it.
This line is the register process.
state-estop is the message we wish to 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 function
self._flip_state(True)
Lambda was used to strip the (w) object before calling the self._flip_state function.
It also allowed use to send self._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.

You will also see code like this (no lambda):

STATUS.connect('current-feed-rate', self._set_feedrate_text)

and 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

This is how Designer sets the attributes of the widget.
This can also be called directly in the widget.

3.3.2. In the Designer properties section

This is the registering of properties in Designer.
The property name is the text that is used in Designer.
These property names cannot be the same as the attributes they represent.
These properties show in 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 with the ACTION library.
It also uses the STATUS library to set whether the button can be clicked
or not.

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

# Instiniate 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. Widget Plugins

We must register our custom widget for Designer to use them.
Here is 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.

5.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"

5.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 Co-ordinate 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"

5.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 such things as select 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