/ docs / src / gui / qtvcp_custom_widgets.txt
qtvcp_custom_widgets.txt
  1  = QTvcp Building Custom Widgets
  2  
  3  == Overview
  4  Building custom widgets allows one to use the Qt Designer editor to place +
  5  a custom widget rather then doing it manually in a handler file. +
  6  A useful custom widgets would be a great way to contribute back to linuxcnc. +
  7  
  8  === Widgets
  9  
 10  Widget is the general name for the UI objects such as buttons and labels in pyQT. +
 11  There are also special widgets made for linuxcnc that make integration easier. +
 12  This widgets can be placed with qt Designer editor - allowing one to see the result +
 13  before actually loading the panel in linuxcnc. +
 14  
 15  === Designer
 16  Qt Designer is a What You See is What You Get editor for placing pyQT widgets. +
 17  It's original intend was for building the graphic widgets for programs. +
 18  We leverage it to build screens and panels for linuxcnc. +
 19  In Qt Designer linuxcnc widgets are split in three heading on the left side of the editor. +
 20  One is for HAL only widgets. +
 21  One is for Linuxcnc controller widgets +
 22  And one is for dialog widgets. +
 23   +
 24  For Designer to add custom widgets to it's editor it must have a plugin added to the right folder. +
 25  
 26  == Custom Hal Widgets
 27  Hal widgets are the simplest to show example of. +
 28  qtvcp/widgets/simple_widgets.py holds many HAL only widgets. +
 29  Lets look at a snippet of simple_widgets.py. +
 30  
 31  [source,python]
 32  ----
 33  #!/usr/bin/python2.7
 34  
 35  ###############################
 36  # Imports
 37  ###############################
 38  from PyQt5 import QtWidgets
 39  from qtvcp.widgets.widget_baseclass import _HalWidgetBase, _HalSensitiveBase
 40  import hal
 41  
 42  ######################
 43  # WIDGET
 44  ######################
 45  
 46  class Lcnc_GridLayout(QtWidgets.QWidget, _HalSensitiveBase):
 47      def __init__(self, parent = None):
 48          super(GridLayout, self).__init__(parent)
 49  ----
 50  
 51  === In the 'Imports' section
 52  
 53  This is where we import libraries that our widget class needs. +
 54  In this case we need access to pyqt's QtWidgets library, linuxcnc's hal library +
 55  and qtvcp's widget baseclass _HalSensitiveBase for automatic HAL pin setup and +
 56  to disable/enable the widget (also known as input sensitivity) +
 57  There is also _HalToggleBase, and _HalScaleBase functions available in the library. +
 58  
 59  === In the 'WIDGET' section
 60  Ok here is our custom widget based on pyQT's QGridLayout widget. +
 61  grid layout allows one to place objects in a grid fashion. +
 62  But this grid_layout also will enable and disable all widgets inside it +
 63  based on the state of a HAL pin. +
 64  Line by Line: +
 65  [source,python]
 66  ----
 67  class Lcnc_GridLayout(QtWidgets.QWidget, _HalSensitiveBase):
 68  ----
 69  This defines the class name and the libraries in inherits from. +
 70  this class named Lcnc_GridLayout inheriets the functions of QWidget and _HalSensitiveBase. +
 71  _HalSensitiveBase is 'subclass' of _HalWidgetBase, The base class of most Qtvcp widgets +
 72  meaning it has all the functions of _HalWidgetBase plus the functions of _HalSensitiveBase. +
 73  It adds the function to make the widget be enabled or disabled based on a HAL input BIT pin. +
 74  [source,python]
 75  ----
 76  def __init__(self, parent = None):
 77  ----
 78  This is the function called when the widget is first made (said instantiated)- this is pretty standard. +
 79  [source,python]
 80  ----
 81  super(GridLayout, self).__init__(parent)
 82  ----
 83  This function initializes our widget's 'Super' classes. +
 84  'Super' just means the inherited baseclasses; QWidget and _HalSensitiveBase +
 85  Pretty standard other the the widget name will change +
 86  
 87  == Custom Controller Widgets using STATUS
 88  Widget that interact with linuxcnc's controller are only a little more complicated +
 89  they require some extra libraries. +
 90  In this cut down example we will add properties that can be changed in Designer. +
 91  This LED indicator widget will respond to selectable linuxcnc controller states. +
 92  
 93  [source,python]
 94  ----
 95  #!/usr/bin/python2.7
 96  
 97  ###############################
 98  # Imports
 99  ###############################
100  from PyQt5.QtCore import pyqtProperty
101  from qtvcp.widgets.led_widget import LED
102  from qtvcp.core import Status
103  
104  ###########################################
105  # **** instantiate libraries section **** #
106  ###########################################
107  STATUS = Status()
108  
109  ##########################################
110  # custom widget class definition
111  ##########################################
112  class StateLED(LED):
113      def __init__(self, parent=None):
114          super(StateLED, self).__init__(parent)
115          self.has_hal_pins = False
116          self.setState(False)
117          self.is_estopped = False
118          self.is_on = False
119          self.invert_state = False
120  
121      def _hal_init(self):
122          if self.is_estopped:
123              STATUS.connect('state-estop', lambda w:self._flip_state(True))
124              STATUS.connect('state-estop-reset', lambda w:self._flip_state(False))
125          elif self.is_on:
126              STATUS.connect('state-on', lambda w:self._flip_state(True))
127              STATUS.connect('state-off', lambda w:self._flip_state(False))
128  
129      def _flip_state(self, data):
130              if self.invert_state:
131                  data = not data
132              self.change_state(data)
133  
134      #########################################################################
135      # Designer properties setter/getters/resetters
136      ########################################################################
137  
138      # invert status
139      def set_invert_state(self, data):
140          self.invert_state = data
141      def get_invert_state(self):
142          return self.invert_state
143      def reset_invert_state(self):
144          self.invert_state = False
145  
146      # machine is estopped status
147      def set_is_estopped(self, data):
148          self.is_estopped = data
149      def get_is_estopped(self):
150          return self.is_estopped
151      def reset_is_estopped(self):
152          self.is_estopped = False
153  
154      # machine is on status
155      def set_is_on(self, data):
156          self.is_on = data
157      def get_is_on(self):
158          return self.is_on
159      def reset_is_on(self):
160          self.is_on = False
161  
162      #######################################
163      # Designer properties
164      #######################################
165      invert_state_status = pyqtProperty(bool, get_invert_state, set_invert_state, reset_invert_state)
166      is_estopped_status = pyqtProperty(bool, get_is_estopped, set_is_estopped, reset_is_estopped)
167      is_on_status = pyqtProperty(bool, get_is_on, set_is_on, reset_is_on)
168  ----
169  
170  === In the 'Imports' section
171  
172  This is where we import libraries that our widget class needs. +
173  We import pyqtProperty so we can interact with the Designer editor. +
174  we import LED because our custom widget is based on it. +
175  We import Status because it gives us status messages from linuxcnc. +
176  
177  === In the 'Instantiate Libraries' section
178  Typically we instantiated the libraries outside of the widget class so that the +
179  reference to it is global - meaning you don't need to use self. in front of it. +
180  By convention we use all capital letters in the name. +
181  
182  === In the 'custom widget class definition' section
183  This is the meat and potatoes of our custom widget. +
184  [source,python]
185  ----
186  class StateLed(LED):
187      def __init__(self, parent=None):
188          super(StateLed, self).__init__(parent)
189          self.has_hal_pins = False
190          self.setState(False)
191          self.is_estopped = False
192          self.is_on = False
193          self.invert_state = False
194  ----
195  This defines the name of our custom widget and what other class it inherits from, in this case +
196  we inherit LED - a Qtvcp widget that represents a status light. +
197  The __init__ is typical of most widgets, it is called when the widget is first made. +
198  the super line is typical of most widgets - it calls the parent (super) widget's initialization code. +
199  then we set some attributes. +
200  self.has_hal_pins is an attribute inherited from Lcnc_Led - we set it here so no HAL Pins are made. +
201  self.setState is inherited from Lcnc_led - we set it to make sure the LED is off. +
202  the other attributes are for the selectable options of our widget. +
203  [source,python]
204  ----
205      def _hal_init(self):
206          if self.is_estopped:
207              STATUS.connect('state-estop', lambda w:self._flip_state(True))
208              STATUS.connect('state-estop-reset', lambda w:self._flip_state(False))
209          elif self.is_on:
210              STATUS.connect('state-on', lambda w:self._flip_state(True))
211              STATUS.connect('state-off', lambda w:self._flip_state(False))
212  ----
213  This function connects STATUS (linuxcnc status message library) to our widget so that the LED will on or off based on +
214  the selected state of the controller. We have two states we can choose from is_estopped or is_on +
215  Depending on which is active our widget get connected to the appropriate STATUS messages. +
216  _hal_int() is called on each widget that inherited _HalWidgetBase, when Qtvcp first builds the screen. +
217  You might wonder why it's called on this widget since we didn't have _HalWidgetBase in our class +
218  definition (class Lcnc_State_Led(Lcnc_Led):) - it's called because Lcnc_Led inherits  _HalWidgetBase +
219   +
220  in this function you have access to some extra information. (though we don't use them in this example) +
221  [source,python]
222  ----
223          self.HAL_GCOMP = the HAL component instance
224          self.HAL_NAME = This widgets name as a string
225          self.QT_OBJECT_ = This widgets pyQt object instance
226          self.QTVCP_INSTANCE_ = The very toplevel Parent Of the screen
227          self.PATHS_ = The instance of Qtvcp's path library
228          self.PREFS_ = the isnstance of an optional preference file
229  ----
230  We could use this information to create HAL pins or look up image paths etc. +
231  [source,python]
232  ----
233              STATUS.connect('state-estop', lambda w:self._flip_state(True))
234  ----
235  lets look at this line more closely. STATUS is very common theme is widget building. +
236  STATUS use GObject message system to send messages to widgets that register to it. +
237  This line is the register process. +
238  'state-estop' is the message we wish to act on. there are many messages available. +
239  'lambda w:self._flip_state(True)' is what happens when the message is caught. +
240  the lambda function accepts the widget instance (w) that GObject sends it and then calls the function +
241  self._flip_state(True) +
242  Lambda was used to strip the (w) object before calling the self._flip_state function. +
243  It also allowed use to send self._flip_state() the True state. +
244  
245  [source,python]
246  ----
247      def _flip_state(self, data):
248              if self.invert_state:
249                  data = not data
250              self.change_state(data)
251  ----
252  This is the function that actually flips the state of the LED. +
253  It is what gets called when the appropriate STATUS message is accepted. +
254   +
255  You will also see code like this (no lambda):
256  [source,python]
257  ----
258  STATUS.connect('current-feed-rate', self._set_feedrate_text)
259  ----
260  and the function called looks like this:
261  [source,python]
262  ----
263      def _set_feedrate_text(self, widget, data):
264  ----
265  in which the widget and any data must be accepted by the function. +
266  
267  ==== In the  'Designer properties setter/getters/resetters' section
268  This is how Designer sets the attributes of the widget. +
269  thes can also be called directly in the widget. +
270  
271  ==== In the 'Designer properties' section
272  This is the registering of properties in Designer. +
273  The property name is the text that is used in Designer. +
274  These property names cannot be the same as the attributes they represent. +
275  These properties show in Designer in the order they appear here. +
276  
277  == Custom Controller Widgets with actions
278  Here is an example of a widget that sets the user reference system. +
279  It changes the machine controller state with the ACTION library. +
280  It also uses the STATUS library to set whether the button can be clicked +
281  or not. +
282  
283  [source,python]
284  ----
285  import os
286  import hal
287  
288  from PyQt5.QtWidgets import QWidget, QToolButton, QMenu, QAction
289  from PyQt5.QtCore import Qt, QEvent, pyqtProperty, QBasicTimer, pyqtSignal
290  from PyQt5.QtGui import QIcon
291  
292  from qtvcp.widgets.widget_baseclass import _HalWidgetBase
293  from qtvcp.widgets.dialog_widget import EntryDialog
294  from qtvcp.core import Status, Action, Info
295  
296  # Instiniate the libraries with global reference
297  # STATUS gives us status messages from linuxcnc
298  # INFO holds ini details
299  # ACTION gives commands to linuxcnc
300  STATUS = Status()
301  INFO = Info()
302  ACTION = Action()
303  
304  class SystemToolButton(QToolButton, _HalWidgetBase):
305      def __init__(self, parent=None):
306          super(SystemToolButton, self).__init__(parent)
307          self._joint = 0
308          self._last = 0
309          self._block_signal = False
310          self._auto_label_flag = True
311          SettingMenu = QMenu()
312          for system in('G54', 'G55', 'G56', 'G57', 'G58', 'G59', 'G59.1', 'G59.2', 'G59.3'):
313  
314              Button = QAction(QIcon('exit24.png'), system, self)
315              Button.triggered.connect(self[system.replace('.','_')])
316              SettingMenu.addAction(Button)
317  
318          self.setMenu(SettingMenu)
319          self.dialog = EntryDialog()
320  
321      def _hal_init(self):
322          if not self.text() == '':
323              self._auto_label_flag = False
324          def homed_on_test():
325              return (STATUS.machine_is_on()
326                      and (STATUS.is_all_homed() or INFO.NO_HOME_REQUIRED))
327  
328          STATUS.connect('state-off', lambda w: self.setEnabled(False))
329          STATUS.connect('state-estop', lambda w: self.setEnabled(False))
330          STATUS.connect('interp-idle', lambda w: self.setEnabled(homed_on_test()))
331          STATUS.connect('interp-run', lambda w: self.setEnabled(False))
332          STATUS.connect('all-homed', lambda w: self.setEnabled(True))
333          STATUS.connect('not-all-homed', lambda w, data: self.setEnabled(False))
334          STATUS.connect('interp-paused', lambda w: self.setEnabled(True))
335          STATUS.connect('user-system-changed', self._set_user_system_text)
336  
337      def G54(self):
338          ACTION.SET_USER_SYSTEM('54')
339  
340      def G55(self):
341          ACTION.SET_USER_SYSTEM('55')
342  
343      def G56(self):
344          ACTION.SET_USER_SYSTEM('56')
345  
346      def G57(self):
347          ACTION.SET_USER_SYSTEM('57')
348  
349      def G58(self):
350          ACTION.SET_USER_SYSTEM('58')
351  
352      def G59(self):
353          ACTION.SET_USER_SYSTEM('59')
354  
355      def G59_1(self):
356          ACTION.SET_USER_SYSTEM('59.1')
357  
358      def G59_2(self):
359          ACTION.SET_USER_SYSTEM('59.2')
360  
361      def G59_3(self):
362          ACTION.SET_USER_SYSTEM('59.3')
363  
364      def _set_user_system_text(self, w, data):
365          convert = { 1:"G54", 2:"G55", 3:"G56", 4:"G57", 5:"G58", 6:"G59", 7:"G59.1", 8:"G59.2", 9:"G59.3"}
366          if self._auto_label_flag:
367              self.setText(convert[int(data)])
368  
369      def ChangeState(self, joint):
370          if int(joint) != self._joint:
371              self._block_signal = True
372              self.setChecked(False)
373              self._block_signal = False
374              self.hal_pin.set(False)
375  
376      ##############################
377      # required class boiler code #
378      ##############################
379  
380      def __getitem__(self, item):
381          return getattr(self, item)
382      def __setitem__(self, item, value):
383          return setattr(self, item, value)
384  
385  ----
386  == Widget Plugins
387  We must register our custom widget for Designer to use them. +
388  Here is a typical samples +
389  they would need to be added to qtvcp/plugins/ +
390  Then qtvcp/plugins/qtvcp_plugin.py would need to be adjusted +
391  to import them. +
392  
393  === Gridlayout example
394  
395  [source,python]
396  ----
397  #!/usr/bin/env python
398  
399  from PyQt5 import QtCore, QtGui
400  from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
401  from qtvcp.widgets.simple_widgets import Lcnc_GridLayout
402  from qtvcp.widgets.qtvcp_icons import Icon
403  ICON = Icon()
404  
405  ####################################
406  # GridLayout
407  ####################################
408  class LcncGridLayoutPlugin(QPyDesignerCustomWidgetPlugin):
409      def __init__(self, parent = None):
410          QPyDesignerCustomWidgetPlugin.__init__(self)
411          self.initialized = False
412      def initialize(self, formEditor):
413          if self.initialized:
414              return
415          self.initialized = True
416      def isInitialized(self):
417          return self.initialized
418      def createWidget(self, parent):
419          return Lcnc_GridLayout(parent)
420      def name(self):
421          return "Lcnc_GridLayout"
422      def group(self):
423          return "Linuxcnc - HAL"
424      def icon(self):
425          return QtGui.QIcon(QtGui.QPixmap(ICON.get_path('lcnc_gridlayout')))
426      def toolTip(self):
427          return "HAL enable/disable GridLayout widget"
428      def whatsThis(self):
429          return ""
430      def isContainer(self):
431          return True
432      def domXml(self):
433          return '<widget class="Lcnc_GridLayout" name="lcnc_gridlayout" />\n'
434      def includeFile(self):
435          return "qtvcp.widgets.simple_widgets"
436  ----
437  
438  === SystemToolbutton example
439  
440  [source,python]
441  ----
442  #!/usr/bin/env python
443  
444  from PyQt5 import QtCore, QtGui
445  from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin
446  from qtvcp.widgets.system_tool_button import SystemToolButton
447  from qtvcp.widgets.qtvcp_icons import Icon
448  ICON = Icon()
449  
450  ####################################
451  # SystemToolButton
452  ####################################
453  class SystemToolButtonPlugin(QPyDesignerCustomWidgetPlugin):
454      def __init__(self, parent = None):
455          super(SystemToolButtonPlugin, self).__init__(parent)
456          self.initialized = False
457      def initialize(self, formEditor):
458          if self.initialized:
459              return
460          self.initialized = True
461      def isInitialized(self):
462          return self.initialized
463      def createWidget(self, parent):
464          return SystemToolButton(parent)
465      def name(self):
466          return "SystemToolButton"
467      def group(self):
468          return "Linuxcnc - Controller"
469      def icon(self):
470          return QtGui.QIcon(QtGui.QPixmap(ICON.get_path('systemtoolbutton')))
471      def toolTip(self):
472          return "Button for selecting a User Co-ordinate System"
473      def whatsThis(self):
474          return ""
475      def isContainer(self):
476          return False
477      def domXml(self):
478          return '<widget class="SystemToolButton" name="systemtoolbutton" />\n'
479      def includeFile(self):
480          return "qtvcp.widgets.system_tool_button"
481  ----
482  
483  === Making a plugin with a MenuEntry dialog box
484  It possible to add an entry to the dialog that pops up when you right +
485  click the widget in the layout. This can do such things as select options +
486  in a more convenient way. This is the plugin used for action buttons. +
487  
488  [source,python]
489  ----
490  #!/usr/bin/env python
491  
492  import sip
493  from PyQt5 import QtCore, QtGui, QtWidgets
494  from PyQt5.QtDesigner import QPyDesignerCustomWidgetPlugin, \
495                  QPyDesignerTaskMenuExtension, QExtensionFactory, \
496                  QDesignerFormWindowInterface, QPyDesignerMemberSheetExtension
497  from qtvcp.widgets.action_button import ActionButton
498  from qtvcp.widgets.qtvcp_icons import Icon
499  ICON = Icon()
500  
501  Q_TYPEID = {
502      'QDesignerContainerExtension':     'org.qt-project.Qt.Designer.Container',
503      'QDesignerPropertySheetExtension': 'org.qt-project.Qt.Designer.PropertySheet',
504      'QDesignerTaskMenuExtension': 'org.qt-project.Qt.Designer.TaskMenu',
505      'QDesignerMemberSheetExtension': 'org.qt-project.Qt.Designer.MemberSheet'
506  }
507  
508  ####################################
509  # ActionBUTTON
510  ####################################
511  class ActionButtonPlugin(QPyDesignerCustomWidgetPlugin):
512  
513      # The __init__() method is only used to set up the plugin and define its
514      # initialized variable.
515      def __init__(self, parent=None):
516          super(ActionButtonPlugin, self).__init__(parent)
517          self.initialized = False
518  
519      # The initialize() and isInitialized() methods allow the plugin to set up
520      # any required resources, ensuring that this can only happen once for each
521      # plugin.
522      def initialize(self, formEditor):
523  
524          if self.initialized:
525              return
526          manager = formEditor.extensionManager()
527          if manager:
528              self.factory = ActionButtonTaskMenuFactory(manager)
529              manager.registerExtensions(self.factory, Q_TYPEID['QDesignerTaskMenuExtension'])
530          self.initialized = True
531  
532      def isInitialized(self):
533          return self.initialized
534  
535      # This factory method creates new instances of our custom widget
536      def createWidget(self, parent):
537          return ActionButton(parent)
538  
539      # This method returns the name of the custom widget class
540      def name(self):
541          return "ActionButton"
542  
543      # Returns the name of the group in Qt Designer's widget box
544      def group(self):
545          return "Linuxcnc - Controller"
546  
547      # Returns the icon
548      def icon(self):
549          return QtGui.QIcon(QtGui.QPixmap(ICON.get_path('actionbutton')))
550  
551      # Returns a tool tip short description
552      def toolTip(self):
553          return "Action button widget"
554  
555      # Returns a short description of the custom widget for use in a "What's
556      # This?" help message for the widget.
557      def whatsThis(self):
558          return ""
559  
560      # Returns True if the custom widget acts as a container for other widgets;
561      def isContainer(self):
562          return False
563  
564      # Returns an XML description of a custom widget instance that describes
565      # default values for its properties.
566      def domXml(self):
567          return '<widget class="ActionButton" name="actionbutton" />\n'
568  
569      # Returns the module containing the custom widget class. It may include
570      # a module path.
571      def includeFile(self):
572          return "qtvcp.widgets.action_button"
573  
574  
575  class ActionButtonDialog(QtWidgets.QDialog):
576  
577     def __init__(self, widget, parent = None):
578  
579        QtWidgets.QDialog.__init__(self, parent)
580  
581        self.widget = widget
582  
583        self.previewWidget = ActionButton()
584  
585        buttonBox = QtWidgets.QDialogButtonBox()
586        okButton = buttonBox.addButton(buttonBox.Ok)
587        cancelButton = buttonBox.addButton(buttonBox.Cancel)
588  
589        okButton.clicked.connect(self.updateWidget)
590        cancelButton.clicked.connect(self.reject)
591  
592        layout = QtWidgets.QGridLayout()
593        self.c_estop = QtWidgets.QCheckBox("Estop Action")
594        self.c_estop.setChecked(widget.estop )
595        layout.addWidget(self.c_estop)
596  
597        layout.addWidget(buttonBox, 5, 0, 1, 2)
598        self.setLayout(layout)
599  
600        self.setWindowTitle(self.tr("Set Options"))
601  
602     def updateWidget(self):
603  
604        formWindow = QDesignerFormWindowInterface.findFormWindow(self.widget)
605        if formWindow:
606            formWindow.cursor().setProperty("estop_action",
607                QtCore.QVariant(self.c_estop.isChecked()))
608        self.accept()
609  
610  class ActionButtonMenuEntry(QPyDesignerTaskMenuExtension):
611  
612      def __init__(self, widget, parent):
613          super(QPyDesignerTaskMenuExtension, self).__init__(parent)
614          self.widget = widget
615          self.editStateAction = QtWidgets.QAction(
616            self.tr("Set Options..."), self)
617          self.editStateAction.triggered.connect(self.updateOptions)
618  
619      def preferredEditAction(self):
620          return self.editStateAction
621  
622      def taskActions(self):
623          return [self.editStateAction]
624  
625      def updateOptions(self):
626          dialog = ActionButtonDialog(self.widget)
627          dialog.exec_()
628  
629  class ActionButtonTaskMenuFactory(QExtensionFactory):
630      def __init__(self, parent = None):
631          QExtensionFactory.__init__(self, parent)
632  
633      def createExtension(self, obj, iid, parent):
634  
635          if not isinstance(obj, ActionButton):
636              return None
637          if iid == Q_TYPEID['QDesignerTaskMenuExtension']:
638              return ActionButtonMenuEntry(obj, parent)
639          elif iid == Q_TYPEID['QDesignerMemberSheetExtension']:
640              return ActionButtonMemberSheet(obj, parent)
641          return None
642  ----