"""TwinCAT widgets are Qt elements that are easily linked to an ADS symbol.
E.g. a label that shows an output value or an input box which changes a
parameter.
The `@pyqtSlot()` is Qt decorator. In many cases it is not essential, but
it's good practice to add it anyway.
"""
from typing import Optional, Any, Union, List
import os
from PyQt5.QtWidgets import (
QMainWindow,
QWidget,
QLabel,
QLineEdit,
QPushButton,
QFrame,
QRadioButton,
QButtonGroup,
QGroupBox,
QAbstractButton,
QVBoxLayout,
QHBoxLayout,
QCheckBox,
QSlider,
)
from PyQt5.QtCore import pyqtSlot, Qt
from PyQt5.QtGui import QIcon
from .tc_base import TcWidget, PACKAGE_DIR
from .custom_widgets import GraphWidget
from ..twincat.symbols import Symbol
class Color: # pylint: disable=too-few-public-methods
"""Collection of useful colours."""
DEFAULT = "#FFFFFF" # White
EDITING = "#FFFFAA" # Yellow-ish
[docs]class TcLabel(QLabel, TcWidget):
"""Label that shows a value."""
def __init__(self, *args, **kwargs):
"""
:param args:
:param kwargs: See :class:`TcWidget`
"""
super().__init__(*args, **kwargs) # Both constructors will be called
# Prevent it being empty from the start
if self._symbol is None and not self.text():
self.setText("NaN") # Default value
# Give the label a frame to visually indicate it is not static
self.setFrameStyle(QFrame.Panel | QFrame.Sunken)
[docs] def twincat_receive(self, value):
self.setText(self.format(value))
[docs]class TcLineEdit(QLineEdit, TcWidget):
"""Readable and writable input box."""
def __init__(self, *args, **kwargs):
"""
:param args:
:param kwargs: See :class:`TcWidget`
"""
super().__init__(*args, **kwargs) # Both constructors will be called
self.textEdited.connect(self.on_text_edited)
self.editingFinished.connect(self.on_editing_finished)
self.setStyleSheet("background-color:" + Color.DEFAULT)
[docs] def twincat_receive(self, value) -> Any:
self.setText(self.format(value))
# `setText` does not fire the `editingFinished` signal
[docs] @pyqtSlot()
def on_editing_finished(self):
"""Called when [Enter] is pressed or box loses focus."""
self.setStyleSheet("background-color:" + Color.DEFAULT)
value = self.text()
self.twincat_send(value)
[docs] @pyqtSlot(str)
def on_text_edited(self, *_value):
"""Callback when text was modified (i.e. on key press)."""
self.setStyleSheet("background-color:" + Color.EDITING)
# `radio_button.setChecked` won't fire a relevant event
[docs]class TcCheckBox(QCheckBox, TcWidget):
"""Checkbox to control a symbol."""
def __init__(self, *args, **kwargs):
"""
Set either value to `None` to send nothing on that state.
For the best results, use 1 and 0 for a boolean variable instead of `True`
and `False`.
:param label: Label of this radio button
:type label: str
:param args:
:param kwargs:
:kwargs:
* `value_checked`: Value when checkbox becomes checked (default: 1)
* `value_unchecked`: Value when checkbox becomes unchecked (default: 0)
* See :class:`TcWidget`
"""
self.value_checked = kwargs.pop("value_checked", 1)
self.value_unchecked = kwargs.pop("value_unchecked", 0)
super().__init__(*args, **kwargs)
self.toggled.connect(self.on_toggled)
[docs] @pyqtSlot()
def on_toggled(self):
"""Callback when box state is togged (either checked or unchecked)."""
if self.isChecked():
if self.value_checked is not None:
self.twincat_send(self.value_checked)
else:
if self.value_unchecked is not None:
self.twincat_send(self.value_unchecked)
[docs] def twincat_receive(self, value):
"""Set checked state if the new value is equal to the is-checked value."""
self.blockSignals(True) # Prevent calling `on_toggled` based on a remote
# change
# Become checked or unchecked
self.setChecked(value == self.value_checked)
# Note: this could result in no radio being checked at
self.blockSignals(False)
[docs]class TcSlider(QWidget, TcWidget):
"""Interactive slider.
Also has built-in slider numbers (unlike the basic QSlider).
This class extends a plain widget so a layout can be added for any labels.
The basic QSlider only supports integer values. To support floating point
numbers too, the slider values are multiplied by a scale (e.g. 100) when writing,
and divided again when reading from the slider.
Use this with the `float` and `float_scale` options. This is done automatically if
`interval` is not an integer.
:ivar slider: QSlider instance
"""
def __init__(self, *args, **kwargs):
"""
:param orientation: Either `QtCore.Qt.Horizontal` (default) or `Vertical`
:param args:
:param kwargs:
:kwargs:
* `min`: Slider minimum value (default: 0)
* `max`: Slider maximum value (default: 100)
* `interval`: Slider interval step size (default: 1)
* `show_labels`: When true (default), show the min and max values with
labels
* `show_value`: When true (default), show the current slider value with a
label
* `float`: When true, QSlider values are scaled to suit floats (default:
False)
* `float_scale`: Factor between QSlider values and real values (default:
100)
* See :class:`TcWidget`
"""
orientation = kwargs.pop("orientation", Qt.Horizontal)
range_min = kwargs.pop("min", 0)
range_max = kwargs.pop("max", 100)
interval = kwargs.pop("interval", 1)
show_labels = kwargs.pop("show_labels", True)
show_value = kwargs.pop("show_value", True)
self.float: bool = kwargs.pop("float", isinstance(interval, float))
self.float_scale: float = kwargs.pop("float_scale", 1.0 / interval)
if range_min > range_max:
raise ValueError("Slider minimum cannot be bigger than the maximum")
if interval > abs(range_max - range_min):
raise ValueError("Interval is bigger than the space between min and max")
self.slider = QSlider(orientation=orientation) # Create the real slider
self.slider.setRange(
self.value_to_slider(range_min), self.value_to_slider(range_max)
)
ticks = self.value_to_slider(interval)
self.slider.setTickInterval(ticks)
self.slider.setSingleStep(ticks)
self.slider.setPageStep(ticks * 5)
if range_min > range_max:
self.slider.setInvertedAppearance(True)
super().__init__(*args, **kwargs) # Both constructors will be called
self.label_min = QLabel(str(range_min))
self.label_max = QLabel(str(range_max))
self.label_value = QLabel("NaN")
if orientation == Qt.Horizontal:
self.layout_slider = QVBoxLayout(self) # Create a layout for this widget
self.layout_labels = QHBoxLayout()
alignment_min = Qt.AlignLeft
alignment_max = Qt.AlignRight
else:
self.layout_slider = QHBoxLayout(self) # Create a layout for this widget
self.layout_labels = QVBoxLayout()
alignment_min = Qt.AlignBottom
alignment_max = Qt.AlignTop # Vertical slider has max at the top
self.label_min.setAlignment(alignment_min)
self.label_max.setAlignment(alignment_max)
self.label_value.setAlignment(Qt.AlignCenter)
self.layout_slider.setContentsMargins(0, 0, 0, 0)
self.layout_labels.setContentsMargins(0, 0, 0, 0)
self.layout_labels.setSpacing(0)
if orientation == Qt.Horizontal:
self.layout_labels.addWidget(self.label_min, alignment_min)
self.layout_slider.addStretch()
self.layout_labels.addWidget(self.label_value, Qt.AlignCenter)
self.layout_slider.addStretch()
self.layout_labels.addWidget(self.label_max, alignment_max)
else:
# For a vertical QSlider the top value is the max
self.layout_labels.addWidget(self.label_max, alignment_max)
self.layout_slider.addStretch()
self.layout_labels.addWidget(self.label_value, Qt.AlignCenter)
self.layout_slider.addStretch()
self.layout_labels.addWidget(self.label_min, alignment_min)
self.layout_slider.addWidget(self.slider)
self.layout_slider.addLayout(self.layout_labels)
if not show_labels:
self.label_min.hide()
self.label_max.hide()
if not show_value:
self.label_value.hide()
# Events
self.slider.valueChanged.connect(self.on_value_changed)
# Note: the event will be triggered while the user is dragging
[docs] def slider_to_value(self, value: int) -> Union[float, int]:
if not self.float:
return value
return value / self.float_scale
[docs] def value_to_slider(self, value: float) -> Union[int, float]:
if not self.float:
return round(value)
return round(value * self.float_scale)
[docs] @pyqtSlot(int)
def on_value_changed(self, new_value):
"""Callback when the slider was changed by the user."""
real_value = self.slider_to_value(new_value)
self.twincat_send(real_value)
[docs] def twincat_receive(self, value) -> Any:
"""On remote value change.
This will be triggered by `on_value_changed` too. A small timeout is added to
prevent a loop between the two callbacks, received changes right after a user
change are ignored.
"""
label_txt = "%.3f" % value if self.float else str(value)
self.label_value.setText(label_txt)
slider_value = self.value_to_slider(value)
self.slider.blockSignals(True) # Prevent calling `on_value_changed`
# based on a remote change
self.slider.setValue(slider_value)
self.slider.blockSignals(False)
[docs]class TcGraph(GraphWidget, TcWidget):
"""Draw rolling graph of symbol values.
TcGraph works only well with `EVENT_TIMER`!
The graph refresh rate is limited to self.FPS, while data is being requested at
`update_freq`.
For research measurements, use a log file or a TwinCAT measurement project
instead. Even with a high `update_freq` there is no guarantee all data is captured!
"""
def __init__(self, *args, **kwargs):
"""
If no symbol for the x-axis is selected, the local time will be used instead.
Note that due to how PyQt events are handled, the local time can be slightly
warped with respect the ADS symbol values.
See :class:`GraphWidget` for more options.
:param args:
:param kwargs:
:kwargs:
* `symbols`: List of symbols to plot (for the y-axis)
* `symbol_x`: Symbol to use on the x-axis (optional)
"""
self._symbol: Optional[List[Symbol]] = None
# Handle symbols
if "symbols" in kwargs:
kwargs["symbol"] = kwargs.pop("symbols")
self.symbol_x: Optional[Symbol] = kwargs.pop("symbol_x", None)
# Other properties
kwargs.setdefault("event_type", self.EVENT_TIMER)
if "labels" not in kwargs:
kwargs["labels"] = [symbol.name for symbol in kwargs["symbol"]]
if kwargs["event_type"] != self.EVENT_TIMER:
raise ValueError("TcGraph can only work with EVENT_TIMER!")
super().__init__(*args, **kwargs)
[docs] def connect_symbol(
self,
new_symbol: Optional[Union[Symbol, List[Symbol]]] = None,
**kwargs,
):
"""Connect to list of symbols (override)."""
if "new_symbols" in kwargs:
new_symbol = kwargs.pop("new_symbols")
# Force to list
if new_symbol is not None and not isinstance(new_symbol, list):
new_symbol = [new_symbol]
super().connect_symbol(new_symbol, **kwargs)
[docs] def on_mass_timeout(self):
"""Callback for the event timer (override).
This assumes the remote read was already performed!
"""
values = [symbol._value for symbol in self._symbol]
value_x = self.symbol_x.read() if self.symbol_x is not None else None
self.add_data(values, value_x)
[docs] def twincat_receive(self, value):
"""Abstract implementation.
All useful code is in on_mass_timeout() instead.
"""
pass
def __del__(self):
"""Destructor."""
# Parent destructor will try to remove notifications, which won't work since
# the _symbol property can be a list
self._symbol = None # No further destruction needed with EVENT_TIMER.
super().__del__()
class TcMainWindow(QMainWindow):
"""Parent class for TwinCAT GUIs.
Extends QMainWindow. The resulting window is empty, but will have a
destructor that neatly closes any TcWidgets first.
To make it easier to navigate the different elements, adhere to:
* Create objects as late as possible
* Save objects as property only if that is really necessary
* Name objects by elements starting with the most general item
(e.g. 'layout_group_drives')
* To save space, create a Layout directly with its parent widget:
`button_layout = QLayout(widget_parent)`
`# widget_parent.addLayout(button_layout) # < Not needed now`
Widgets consist of layouts. Layouts contain widgets.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.setWindowTitle("TwinCAT GUI")
icon_path = os.path.join(PACKAGE_DIR, "resources/icon.ico")
self.setWindowIcon(QIcon(icon_path))
def find_tc_widgets(self) -> List[TcWidget]:
"""Find all children of the TcWidget type (recursively)."""
for widget in self.findChildren(TcWidget):
yield widget
def closeEvent(self, event):
"""On window close."""
# An error will occur when a callback is fired to a widget that has
# already been removed, so on the closing of the window we make sure
# to clear callbacks to TcWidgets
for widget in self.find_tc_widgets():
widget.connect_symbol(None)
super().closeEvent(event) # Continue to parent event handler