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