1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-22 16:24:34 +02:00

Compare commits

..

33 Commits

Author SHA1 Message Date
semantic-release 6fa7ca8f09 0.112.1
Automatically generated by python-semantic-release
2024-09-19 09:05:41 +00:00
wyzula_j b2f7d3c5f3 fix: test e2e dap wait_for_fit 2024-09-19 09:30:26 +02:00
wakonig_k e3b5e338bf docs(dap_combo_box): updated screenshot 2024-09-18 14:15:06 +02:00
wakonig_k c8e614b575 docs(device_box): updated screenshot 2024-09-18 14:00:10 +02:00
semantic-release 8e44ca1ad0 0.112.0
Automatically generated by python-semantic-release
2024-09-17 08:13:25 +00:00
guijar_m 286ad7196b feat: console: various improvements, auto-adapt rows to widget size, Qt Designer plugin 2024-09-17 10:08:49 +02:00
semantic-release adef25f4e2 0.111.0
Automatically generated by python-semantic-release
2024-09-17 04:41:08 +00:00
wakonig_k 60f7d54e2b docs(position_indicator): updated position indicator documentation and added designer properties 2024-09-16 16:56:58 +02:00
wakonig_k dd932dd8f3 fix(position_indicator): fixed user access 2024-09-16 16:56:58 +02:00
wakonig_k d3c1a1b2ed fix(generate_cli): fixed type annotations 2024-09-16 16:56:58 +02:00
wakonig_k 7ea4a482e7 fix(positioner_box): visual improvements to the positioner_box and positioner_control_line 2024-09-16 13:34:39 +02:00
wakonig_k 9045323049 fix(palette viewer): fixed background for tool tip 2024-09-14 18:57:50 +02:00
wakonig_k d15b22250f feat(position_indicator): improved design and added more customization options 2024-09-14 18:33:00 +02:00
semantic-release 5557bfe717 0.110.0
Automatically generated by python-semantic-release
2024-09-12 08:28:50 +00:00
wakonig_k a8576c164c feat(palette_viewer): added widget to display the current palette and accent colors 2024-09-12 08:58:54 +02:00
semantic-release f5807ec5cd 0.109.1
Automatically generated by python-semantic-release
2024-09-09 15:50:30 +00:00
appel_c b0d786b991 fix: refactor textbox widget, remove inheritance, adhere to bec style; closes #324 2024-09-09 17:41:27 +02:00
semantic-release 774044d2a7 0.109.0
Automatically generated by python-semantic-release
2024-09-06 17:30:40 +00:00
wakonig_k 84a59f70ee feat(accent colors): added helper function to get all accent colors 2024-09-06 19:26:17 +02:00
wakonig_k de303f0227 fix(theme): fixed theme access for themecontainer 2024-09-06 19:26:17 +02:00
semantic-release cb2131b1de 0.108.0
Automatically generated by python-semantic-release
2024-09-06 15:18:45 +00:00
wakonig_k 7d07cea946 docs(progressbar): added docs 2024-09-06 17:09:45 +02:00
wakonig_k f6d1d0bbe3 feat(progressbar): added bec progressbar 2024-09-06 17:09:45 +02:00
wakonig_k a52182dca9 feat(generate_cli): added support for property and qproperty setter 2024-09-06 17:09:45 +02:00
semantic-release 6731b655e7 0.107.0
Automatically generated by python-semantic-release
2024-09-06 13:34:20 +00:00
appel_c bd126dddbb refactor: change style to bec_accent_colors 2024-09-06 15:11:56 +02:00
appel_c e6976dc151 docs: extend waveform docs 2024-09-06 12:46:35 +02:00
appel_c b1aff6d791 test: add tests, including extension to end-2-end test 2024-09-06 12:46:35 +02:00
appel_c 7bdca84314 feat: add roi select for dap, allow automatic clear curves on plot request 2024-09-06 12:46:35 +02:00
semantic-release 6b3ea0101e 0.106.0
Automatically generated by python-semantic-release
2024-09-05 12:52:33 +00:00
wyzula_j 06d7741622 feat(plot_base): toggle to switch outer axes for plotting widgets 2024-09-05 14:43:20 +02:00
appel_c 6b15abcc73 test: fix tests 2024-09-04 17:59:36 +02:00
appel_c 998a745133 refactor: use DAPComboBox in curve_dialog selection 2024-09-04 17:18:40 +02:00
57 changed files with 2292 additions and 519 deletions
+104 -102
View File
@@ -1,5 +1,109 @@
# CHANGELOG
## v0.112.1 (2024-09-19)
### Documentation
* docs(dap_combo_box): updated screenshot ([`e3b5e33`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e3b5e338bfaec276979183fb6d79ab41a7ca21e1))
* docs(device_box): updated screenshot ([`c8e614b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c8e614b575b48be788a6389a7aa0cfa033d86ab8))
### Fix
* fix: test e2e dap wait_for_fit ([`b2f7d3c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b2f7d3c5f3f4bf00cc628f788e2c278ebb5688ae))
## v0.112.0 (2024-09-17)
### Feature
* feat: console: various improvements, auto-adapt rows to widget size, Qt Designer plugin ([`286ad71`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/286ad7196b0b8562d648fb304eab7d759b6a959b))
## v0.111.0 (2024-09-17)
### Documentation
* docs(position_indicator): updated position indicator documentation and added designer properties ([`60f7d54`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/60f7d54e2b4c3129de6c95729b8b4aea1757174f))
### Feature
* feat(position_indicator): improved design and added more customization options ([`d15b222`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d15b22250fbceb708d89872c0380693e04acb107))
### Fix
* fix(position_indicator): fixed user access ([`dd932dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/dd932dd8f3910ab67ec8403124f4e176d048e542))
* fix(generate_cli): fixed type annotations ([`d3c1a1b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d3c1a1b2edcba7afea9d369820fa7974ac29c333))
* fix(positioner_box): visual improvements to the positioner_box and positioner_control_line ([`7ea4a48`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7ea4a482e7cd9499a7268ac887b345cab01632aa))
* fix(palette viewer): fixed background for tool tip ([`9045323`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9045323049d2a39c36fc8845f3b2883d6933436b))
## v0.110.0 (2024-09-12)
### Feature
* feat(palette_viewer): added widget to display the current palette and accent colors ([`a8576c1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a8576c164cad17746ec4fcd5c775fb78f70c055c))
## v0.109.1 (2024-09-09)
### Fix
* fix: refactor textbox widget, remove inheritance, adhere to bec style; closes #324 ([`b0d786b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b0d786b991677c0846a0c6ba3f2252d48d94ccaa))
## v0.109.0 (2024-09-06)
### Feature
* feat(accent colors): added helper function to get all accent colors ([`84a59f7`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/84a59f70eed6d8a3c3aeeabc77a5f9ea4e864f61))
### Fix
* fix(theme): fixed theme access for themecontainer ([`de303f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/de303f0227fc9d3a74a0410f1e7999ac5132273c))
## v0.108.0 (2024-09-06)
### Documentation
* docs(progressbar): added docs ([`7d07cea`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7d07cea946f9c884477b01bebfb60b332ff09e0a))
### Feature
* feat(progressbar): added bec progressbar ([`f6d1d0b`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/f6d1d0bbe3ba30a3b7291cd36a1f7f8e6bd5b895))
* feat(generate_cli): added support for property and qproperty setter ([`a52182d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a52182dca978833bfc3fad755c596d3a2ef45c42))
## v0.107.0 (2024-09-06)
### Documentation
* docs: extend waveform docs ([`e6976dc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e6976dc15105209852090a00a97b7cda723142e9))
### Feature
* feat: add roi select for dap, allow automatic clear curves on plot request ([`7bdca84`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7bdca8431496fe6562d2c28f5a6af869d1a2e654))
### Refactor
* refactor: change style to bec_accent_colors ([`bd126dd`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bd126dddbbec3e6c448cce263433d328d577c5c0))
### Test
* test: add tests, including extension to end-2-end test ([`b1aff6d`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b1aff6d791ff847eb2f628e66ccaa4672fdeea08))
## v0.106.0 (2024-09-05)
### Feature
* feat(plot_base): toggle to switch outer axes for plotting widgets ([`06d7741`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/06d7741622aea8556208cd17cae521c37333f8b6))
### Refactor
* refactor: use DAPComboBox in curve_dialog selection ([`998a745`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/998a7451335b1b35c3e18691d3bab8d882e2d30b))
### Test
* test: fix tests ([`6b15abc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6b15abcc73170cb49292741a619a08ee615e6250))
## v0.105.0 (2024-09-04)
### Feature
@@ -44,114 +148,12 @@
## v0.103.0 (2024-09-04)
### Ci
* ci: prefill variables for manual pipeline start ([`158c19e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/158c19eda771562a325fd59405f9fd4cb9a17ed6))
### Feature
* feat(vscode): open vscode on a free port ([`52da835`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/52da835803f2453096a8b7df23bee5fdf93ae2bb))
* feat(website): added method to wait until the webpage is loaded ([`9be19d4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9be19d4abebad08c5fc6bea936dd97475fe8f628))
### Fix
* fix(theme): fixed segfault for webengineview for auto updates ([`9866075`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9866075100577948755b563dc7b7dc4cdc60d040))
### Test
* test(webview): fixed tests after refactoring ([`d5eb30c`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d5eb30cd7df4cb0dc3275dd362768afc211eaf2d))
* test(vscode): popen call does not have to be the only one ([`39f98ec`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/39f98ec223ba8b59e478ac788c08c59ffe886b4e))
## v0.102.0 (2024-09-04)
### Documentation
* docs(buttons): buttons section of docs split to appearance and queue buttons ([`047aa26`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/047aa26a60220c826cc1375cf81daf11d1f3ab5c))
* docs(tests): added tests tutorial for widget ([`18d8561`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/18d8561c965d149a7662085f7dbe2a39a8c4a475))
### Feature
* feat(queue): BECQueue controls extended with Resume, Stop, Abort, Reset buttons ([`0d7c10e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0d7c10e670e4937787e1afaa19ca8259ac752486))
### Fix
* fix(queue_reset_button): queue reset has to be confirmed with msgBox ([`9dd43aa`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9dd43aa1fd3991368002605df4389a7a7271011b))
### Refactor
* refactor(tests): positioner box test changed to use create_widget fixture ([`df5eff3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/df5eff3147c79ff0278e6a5a09c8f73d5236aed3))
## v0.101.0 (2024-09-02)
### Feature
* feat: add Dap dialog widget ([`9781b77`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/9781b77de27b2810fbb1047a61b1832dd186db01))
### Refactor
* refactor: add docs, cleanup ([`61ecf49`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/61ecf491e52bfbfa0d5a84764a9095310659043d))
## v0.100.0 (2024-09-01)
### Documentation
* docs(becwidget): improvements to the bec widget base class docs; fixed type hint import for sphinx ([`99d5e8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99d5e8e71c7f89a53d7967126f4056dde005534c))
### Feature
* feat(theme): added theme handler to bec widget base class; added tests ([`7fb938a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7fb938a8506685278ee5eeb6fe9a03f74b713cf8))
### Fix
* fix(pyqt slot): removed slot decorator to avoid problems with pyqt6 ([`6c1f89a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6c1f89ad39b7240ab1d1c1123422b99ae195bf01))
## v0.99.15 (2024-08-31)
### Fix
* fix(theme): update pg axes on theme update ([`af23e74`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af23e74f71152f4abc319ab7b45e65deefde3519))
* fix(positioner_box): fixed positioner box dialog; added test; closes #332 ([`0bf1cf9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0bf1cf9b8ab2f9171d5ff63d4e3672eb93e9a5fa))
## v0.99.14 (2024-08-30)
### Fix
* fix(color_button): signal and slot added for selecting color and for emitting color after change ([`99a98de`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99a98de8a3b7a83d71e4b567e865ac6f5c62a754))
* fix(color_button): inheritance changed to QWidget ([`3c0e501`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/3c0e501c56227d4d98ff0ac2186ff5065bff8d7a))
## v0.99.13 (2024-08-30)
### Documentation
* docs: minor updates to the widget tutorial ([`ec9c8f2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/ec9c8f29633364c45ebd998a5411d428c1ce488d))
* docs(widget tutorial): step by step guide added ([`b32ced8`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/b32ced85fff628a9e1303a781630cdae3865238e))
### Fix
* fix(dark mode button): fixed dark mode button state for external updates, including auto ([`a3110d9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a3110d98147295dcb1f9353f9aaf5461cba9232a))
## v0.99.12 (2024-08-29)
### Fix
* fix(toolbar): widget action added ([`2efd487`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2efd48736cbe04e84533f7933c552ea8274e2162))
* fix(reset_button): reset button added ([`6ed1efc`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6ed1efc6af193908f70aa37fb73157d2ca6a62f4))
* fix(abort_button): abort button added; some minor fixes ([`a568633`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/a568633c3206a8c26069d140f2d9a548bf4124b0))
## v0.99.11 (2024-08-29)
### Fix
* fix(resume_button): resume button added ([`8be8295`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/8be8295b2b38f36da210ab36c5da6d0a00e330cc))
### Refactor
* refactor(icons): general app icon changed; jupyter app icon changed to material icon ([`5d73fe4`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/5d73fe455a568ad40a9fadc5ce6e249d782ad20d))
+152 -22
View File
@@ -1,5 +1,7 @@
# This file was automatically generated by generate_cli.py
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
@@ -19,6 +21,7 @@ class Widgets(str, enum.Enum):
BECFigure = "BECFigure"
BECImageWidget = "BECImageWidget"
BECMotorMapWidget = "BECMotorMapWidget"
BECProgressBar = "BECProgressBar"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
BECWaveformWidget = "BECWaveformWidget"
@@ -28,6 +31,7 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
ResetButton = "ResetButton"
@@ -1653,6 +1657,15 @@ class BECPlotBase(RPCBase):
y(bool): Show grid on the y-axis.
"""
@rpc_call
def set_outer_axes(self, show: "bool" = True):
"""
Set the outer axes of the plot widget.
Args:
show(bool): Show the outer axes.
"""
@rpc_call
def lock_aspect_ratio(self, lock):
"""
@@ -1684,6 +1697,57 @@ class BECPlotBase(RPCBase):
"""
class BECProgressBar(RPCBase):
@rpc_call
def set_value(self, value):
"""
Set the value of the progress bar.
Args:
value (float): The value to set.
"""
@rpc_call
def set_maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
Args:
maximum (float): The maximum value.
"""
@rpc_call
def set_minimum(self, minimum: float):
"""
Set the minimum value of the progress bar.
Args:
minimum (float): The minimum value.
"""
@property
@rpc_call
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
Examples:
>>> progressbar.label_template = "$value / $maximum - $percentage %"
>>> progressbar.label_template = "$value / $percentage %"
"""
@label_template.setter
@rpc_call
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
Examples:
>>> progressbar.label_template = "$value / $maximum - $percentage %"
>>> progressbar.label_template = "$value / $percentage %"
"""
class BECQueue(RPCBase):
@property
@rpc_call
@@ -1878,7 +1942,7 @@ class BECWaveform(RPCBase):
"""
@rpc_call
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict | pd.DataFrame":
def get_all_data(self, output: "Literal['dict', 'pandas']" = "dict") -> "dict":
"""
Extract all curve data into a dictionary or a pandas DataFrame.
@@ -2041,6 +2105,25 @@ class BECWaveform(RPCBase):
size(int): Font size of the legend.
"""
@rpc_call
def toggle_roi(self, toggled: "bool") -> "None":
"""
Toggle the linear region selector on the plot.
Args:
toggled(bool): If True, enable the linear region selector.
"""
@rpc_call
def select_roi(self, region: "tuple[float, float]"):
"""
Set the fit region of the plot widget. At the moment only a single region is supported.
To remove the roi region again, use toggle_roi_region
Args:
region(tuple[float, float]): The fit region.
"""
class BECWaveformWidget(RPCBase):
@property
@@ -2312,12 +2395,30 @@ class BECWaveformWidget(RPCBase):
Export the plot widget to Matplotlib.
"""
@rpc_call
def toggle_roi(self, checked: "bool"):
"""
Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
@rpc_call
def select_roi(self, region: "tuple"):
"""
Set the region of interest of the plot widget.
Args:
region(tuple): Region of interest.
"""
class DapComboBox(RPCBase):
@rpc_call
def select_y_axis(self, y_axis: str):
"""
Receive update signal for the y axis.
Slot to update the y axis.
Args:
y_axis(str): Y axis.
@@ -2326,19 +2427,19 @@ class DapComboBox(RPCBase):
@rpc_call
def select_x_axis(self, x_axis: str):
"""
Receive update signal for the x axis.
Slot to update the x axis.
Args:
x_axis(str): X axis.
"""
@rpc_call
def select_fit(self, fit_name: str | None):
def select_fit_model(self, fit_name: str | None):
"""
Select current fit.
Slot to update the fit model.
Args:
default_device(str): Default device name.
Args:
default_device(str): Default device name.
"""
@@ -2441,6 +2542,45 @@ class LMFitDialog(RPCBase):
"""
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
"""
None
"""
@rpc_call
def set_range(self, min_value: float, max_value: float):
"""
Set the range of the position indicator
Args:
min_value(float): Minimum value of the range
max_value(float): Maximum value of the range
"""
@property
@rpc_call
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
@property
@rpc_call
def indicator_width(self):
"""
Property to get the width of the indicator
"""
@property
@rpc_call
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
class PositionerBox(RPCBase):
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
@@ -2816,31 +2956,21 @@ class StopButton(RPCBase):
class TextBox(RPCBase):
@rpc_call
def set_color(self, background_color: str, font_color: str) -> None:
def set_plain_text(self, text: str) -> None:
"""
Set the background color of the widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
"""
@rpc_call
def set_text(self, text: str) -> None:
"""
Set the text of the widget.
Set the plain text of the widget.
Args:
text (str): The text to set.
"""
@rpc_call
def set_font_size(self, size: int) -> None:
def set_html_text(self, text: str) -> None:
"""
Set the font size of the text in the widget.
Set the HTML text of the widget.
Args:
size (int): The font size to set.
text (str): The text to set.
"""
+21 -3
View File
@@ -8,6 +8,7 @@ import sys
import black
import isort
from qtpy.QtCore import Property as QtProperty
from bec_widgets.utils.generate_designer_plugin import DesignerPluginGenerator
from bec_widgets.utils.plugin_utils import BECClassContainer, get_rpc_classes
@@ -30,6 +31,7 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
@@ -90,11 +92,27 @@ class {class_name}(RPCBase):"""
self.content += """...
"""
for method in cls.USER_ACCESS:
obj = getattr(cls, method)
if isinstance(obj, property):
self.content += """
is_property_setter = False
obj = getattr(cls, method, None)
if obj is None:
obj = getattr(cls, method.split(".setter")[0], None)
is_property_setter = True
method = method.split(".setter")[0]
if obj is None:
raise AttributeError(
f"Method {method} not found in class {cls.__name__}. Please check the USER_ACCESS list."
)
if isinstance(obj, (property, QtProperty)):
# for the cli, we can map qt properties to regular properties
if is_property_setter:
self.content += f"""
@{method}.setter
@rpc_call"""
else:
self.content += """
@property
@rpc_call"""
sig = str(inspect.signature(obj.fget))
doc = inspect.getdoc(obj.fget)
else:
+7 -2
View File
@@ -86,10 +86,15 @@ class BECWidgetsCLIServer:
return obj
def run_rpc(self, obj, method, args, kwargs):
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
res = method_obj
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
sig = inspect.signature(method_obj)
if sig.parameters:
@@ -245,5 +250,5 @@ def main():
if __name__ == "__main__": # pragma: no cover
sys.argv = ["bec_widgets.cli.server", "--id", "test", "--gui_class", "BECDockArea"]
sys.argv = ["bec_widgets.cli.server", "--id", "e2860", "--gui_class", "BECDockArea"]
main()
+183
View File
@@ -0,0 +1,183 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QApplication,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
class PaletteViewer(BECWidget, QWidget):
"""
This class is a widget that displays current palette colors.
"""
ICON_NAME = "palette"
def __init__(self, *args, parent=None, **kwargs):
super().__init__(*args, theme_update=True, **kwargs)
QWidget.__init__(self, parent=parent)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)
layout.addWidget(dark_mode_button)
# Create a scroll area to hold the color boxes
scroll_area = QScrollArea(self)
scroll_area.setWidgetResizable(True)
scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create a frame to hold the color boxes
self.frame = QFrame(self)
self.frame_layout = QGridLayout(self.frame)
self.frame_layout.setSpacing(0)
self.frame_layout.setContentsMargins(0, 0, 0, 0)
scroll_area.setWidget(self.frame)
layout.addWidget(scroll_area)
self.setLayout(layout)
self.update_palette()
def apply_theme(self, theme) -> None:
"""
Apply the theme to the widget.
Args:
theme (str): The theme to apply.
"""
self.update_palette()
def clear_palette(self) -> None:
"""
Clear the palette colors from the frame.
Recursively removes all widgets and layouts in the frame layout.
"""
# Iterate over all items in the layout in reverse to safely remove them
for i in reversed(range(self.frame_layout.count())):
item = self.frame_layout.itemAt(i)
# If the item is a layout, clear its contents
if isinstance(item, QHBoxLayout):
# Recursively remove all widgets from the layout
for j in reversed(range(item.count())):
widget = item.itemAt(j).widget()
if widget:
item.removeWidget(widget)
widget.deleteLater()
self.frame_layout.removeItem(item)
# If the item is a widget, remove and delete it
elif item.widget():
widget = item.widget()
self.frame_layout.removeWidget(widget)
widget.deleteLater()
def update_palette(self) -> None:
"""
Update the palette colors in the frame.
"""
self.clear_palette()
palette_label = QLabel("Palette Colors (e.g. palette.windowText().color())")
palette_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(palette_label, 0, 0)
palette = get_theme_palette()
# Add the palette colors (roles) to the frame
palette_roles = [
palette.windowText,
palette.toolTipText,
palette.placeholderText,
palette.text,
palette.buttonText,
palette.highlight,
palette.link,
palette.light,
palette.midlight,
palette.mid,
palette.shadow,
palette.button,
palette.brightText,
palette.toolTipBase,
palette.alternateBase,
palette.dark,
palette.base,
palette.window,
palette.highlightedText,
palette.linkVisited,
]
offset = 1
for i, pal in enumerate(palette_roles):
i += offset
color = pal().color()
label_layout = QHBoxLayout()
color_label = QLabel(f"{pal().color().name()} ({pal.__name__})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i, 0)
# add a horizontal spacer
spacer = QLabel()
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self.frame_layout.addWidget(spacer, i + 1, 0)
accent_colors_label = QLabel("Accent Colors (e.g. accent_colors.default)")
accent_colors_label.setStyleSheet("font-weight: bold;")
self.frame_layout.addWidget(accent_colors_label, i + 2, 0)
accent_colors = get_accent_colors()
items = [
(accent_colors.default, "default"),
(accent_colors.success, "success"),
(accent_colors.warning, "warning"),
(accent_colors.emergency, "emergency"),
(accent_colors.highlight, "highlight"),
]
offset = len(palette_roles) + 2
for i, (color, name) in enumerate(items):
i += offset
label_layout = QHBoxLayout()
color_label = QLabel(f"{color.name()} ({name})")
background_label = self.background_label_with_clipboard(color)
label_layout.addWidget(color_label)
label_layout.addWidget(background_label)
self.frame_layout.addLayout(label_layout, i + 2, 0)
def background_label_with_clipboard(self, color) -> QLabel:
"""
Create a label with a background color that copies the color to the clipboard when clicked.
Args:
color (QColor): The color to display in the background.
Returns:
QLabel: The label with the background color.
"""
button = QLabel()
button.setStyleSheet(f"QLabel {{ background-color: {color.name()}; }}")
button.setToolTip("Click to copy color to clipboard")
button.setCursor(Qt.PointingHandCursor)
button.mousePressEvent = lambda event: QApplication.clipboard().setText(color.name())
return button
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
viewer = PaletteViewer()
viewer.show()
sys.exit(app.exec_())
+1 -1
View File
@@ -71,7 +71,7 @@ class BECWidget(BECConnector):
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme["theme"]
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
+21 -6
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import itertools
import re
from typing import Literal
from typing import TYPE_CHECKING, Literal
import bec_qthemes
import numpy as np
@@ -8,25 +10,38 @@ import pyqtgraph as pg
from bec_qthemes._os_appearance.listener import OSThemeSwitchListener
from pydantic_core import PydanticCustomError
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication, QPushButton, QToolButton
from qtpy.QtWidgets import QApplication
if TYPE_CHECKING:
from bec_qthemes._main import AccentColors
def get_theme_palette():
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
theme = "dark"
else:
theme = QApplication.instance().theme["theme"]
theme = QApplication.instance().theme.theme
return bec_qthemes.load_palette(theme)
def get_accent_colors() -> AccentColors | None:
"""
Get the accent colors for the current theme. These colors are extensions of the color palette
and are used to highlight specific elements in the UI.
"""
if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"):
return None
return QApplication.instance().theme.accent_colors
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme["theme"] = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme["theme"])
app.theme.theme = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme.theme)
apply_theme(app.os_listener._theme.lower())
@@ -124,7 +139,7 @@ class Colors:
color_index = int(color_selection[ii])
color = cmap_colors[color_index]
app = QApplication.instance()
if hasattr(app, "theme") and app.theme["theme"] == "light":
if hasattr(app, "theme") and app.theme.theme == "light":
background = 255
else:
background = 0
@@ -0,0 +1,72 @@
""" Module for a thin wrapper (LinearRegionWrapper) around the LinearRegionItem in pyqtgraph.
The class is mainly designed for usage with the BECWaveform and 1D plots. """
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot
from qtpy.QtGui import QColor
class LinearRegionWrapper(QObject):
"""Wrapper class for the LinearRegionItem in pyqtgraph for 1D plots (BECWaveform)
Args:
plot_item (pg.PlotItem): The plot item to add the region selector to.
parent (QObject): The parent object.
color (QColor): The color of the region selector.
hover_color (QColor): The color of the region selector when the mouse is over it.
"""
# Signal with the region tuble (start, end)
region_changed = Signal(tuple)
def __init__(
self, plot_item: pg.PlotItem, color: QColor = None, hover_color: QColor = None, parent=None
):
super().__init__(parent)
self._edge_width = 2
self.plot_item = plot_item
self.linear_region_selector = pg.LinearRegionItem()
self.proxy = None
self.change_roi_color((color, hover_color))
# Slot for changing the color of the region selector (edge and fill)
@Slot(tuple)
def change_roi_color(self, colors: tuple[QColor | str | tuple, QColor | str | tuple]):
"""Change the color and hover color of the region selector.
Hover color means the color when the mouse is over the region.
Args:
colors (tuple): Tuple with the color and hover color
"""
color, hover_color = colors
if color is not None:
self.linear_region_selector.setBrush(pg.mkBrush(color))
if hover_color is not None:
self.linear_region_selector.setHoverBrush(pg.mkBrush(hover_color))
@Slot()
def add_region_selector(self):
"""Add the region selector to the plot item"""
self.plot_item.addItem(self.linear_region_selector)
# Use proxy to limit the update rate of the region change signal to 10Hz
self.proxy = pg.SignalProxy(
self.linear_region_selector.sigRegionChanged,
rateLimit=10,
slot=self._region_change_proxy,
)
@Slot()
def remove_region_selector(self):
"""Remove the region selector from the plot item"""
self.proxy.disconnect()
self.proxy = None
self.plot_item.removeItem(self.linear_region_selector)
def _region_change_proxy(self):
"""Emit the region change signal"""
region = self.linear_region_selector.getRegion()
self.region_changed.emit(region)
def cleanup(self):
"""Cleanup the widget."""
self.remove_region_selector()
@@ -0,0 +1 @@
{'files': ['bec_progressbar.py']}
@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar
DOM_XML = """
<ui language='c++'>
<widget class='BECProgressBar' name='bec_progress_bar'>
</widget>
</ui>
"""
class BECProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECProgressBar(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Utils"
def icon(self):
return designer_material_icon(BECProgressBar.ICON_NAME)
def includeFile(self):
return "bec_progress_bar"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECProgressBar"
def toolTip(self):
return "A custom progress bar with smooth transitions and a modern design."
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,258 @@
import sys
from string import Template
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot
from qtpy.QtGui import QColor, QPainter, QPainterPath
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
class BECProgressBar(BECWidget, QWidget):
"""
A custom progress bar with smooth transitions. The displayed text can be customized using a template.
"""
USER_ACCESS = [
"set_value",
"set_maximum",
"set_minimum",
"label_template",
"label_template.setter",
]
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
accent_colors = get_accent_colors()
# internal values
self._oversampling_factor = 50
self._value = 0
self._target_value = 0
self._maximum = 100 * self._oversampling_factor
# User values
self._user_value = 0
self._user_minimum = 0
self._user_maximum = 100
self._label_template = "$value / $maximum - $percentage %"
# Color settings
self._background_color = QColor(30, 30, 30)
self._progress_color = accent_colors.highlight # QColor(210, 55, 130)
self._completed_color = accent_colors.success
self._border_color = QColor(50, 50, 50)
# layout settings
self._value_animation = QPropertyAnimation(self, b"_progressbar_value")
self._value_animation.setDuration(200)
self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
# label on top of the progress bar
self.center_label = QLabel(self)
self.center_label.setAlignment(Qt.AlignCenter)
self.center_label.setStyleSheet("color: white;")
self.center_label.setMinimumSize(0, 0)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.center_label)
self.setLayout(layout)
self.update()
@Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.")
def label_template(self):
"""
The template for the center label. Use $value, $maximum, and $percentage to insert the values.
Examples:
>>> progressbar.label_template = "$value / $maximum - $percentage %"
>>> progressbar.label_template = "$value / $percentage %"
"""
return self._label_template
@label_template.setter
def label_template(self, template):
self._label_template = template
self.set_value(self._user_value)
self.update()
@Property(float, designable=False)
def _progressbar_value(self):
"""
The current value of the progress bar.
"""
return self._value
@_progressbar_value.setter
def _progressbar_value(self, val):
self._value = val
self.update()
def _update_template(self):
template = Template(self._label_template)
return template.safe_substitute(
value=self._user_value,
maximum=self._user_maximum,
percentage=int((self.map_value(self._user_value) / self._maximum) * 100),
)
@Slot(float)
@Slot(int)
def set_value(self, value):
"""
Set the value of the progress bar.
Args:
value (float): The value to set.
"""
if value > self._user_maximum:
value = self._user_maximum
elif value < self._user_minimum:
value = self._user_minimum
self._target_value = self.map_value(value)
self._user_value = value
self.center_label.setText(self._update_template())
self.animate_progress()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
rect = self.rect().adjusted(10, 0, -10, -1)
# Draw background
painter.setBrush(self._background_color)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(rect, 10, 10) # Rounded corners
# Draw border
painter.setBrush(Qt.NoBrush)
painter.setPen(self._border_color)
painter.drawRoundedRect(rect, 10, 10)
# Determine progress color based on completion
if self._value >= self._maximum:
current_color = self._completed_color
else:
current_color = self._progress_color
# Set clipping region to preserve the background's rounded corners
progress_rect = rect.adjusted(
0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0
)
clip_path = QPainterPath()
clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners
painter.setClipPath(clip_path)
# Draw progress bar
painter.setBrush(current_color)
painter.drawRect(progress_rect) # Less rounded, no additional rounding
painter.end()
def animate_progress(self):
"""
Animate the progress bar from the current value to the target value.
"""
self._value_animation.stop()
self._value_animation.setStartValue(self._value)
self._value_animation.setEndValue(self._target_value)
self._value_animation.start()
@Property(float)
def maximum(self):
"""
The maximum value of the progress bar.
"""
return self._user_maximum
@maximum.setter
def maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
"""
self.set_maximum(maximum)
@Property(float)
def minimum(self):
"""
The minimum value of the progress bar.
"""
return self._user_minimum
@minimum.setter
def minimum(self, minimum: float):
self.set_minimum(minimum)
@Property(float)
def initial_value(self):
"""
The initial value of the progress bar.
"""
return self._user_value
@initial_value.setter
def initial_value(self, value: float):
self.set_value(value)
@Slot(float)
def set_maximum(self, maximum: float):
"""
Set the maximum value of the progress bar.
Args:
maximum (float): The maximum value.
"""
self._user_maximum = maximum
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
@Slot(float)
def set_minimum(self, minimum: float):
"""
Set the minimum value of the progress bar.
Args:
minimum (float): The minimum value.
"""
self._user_minimum = minimum
self.set_value(self._user_value) # Update the value to fit the new range
self.update()
def map_value(self, value: float):
"""
Map the user value to the range [0, 100*self._oversampling_factor] for the progress
"""
return (
(value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum
)
if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
progressBar = BECProgressBar()
progressBar.show()
progressBar.set_minimum(-100)
progressBar.set_maximum(0)
# Example of setting values
def update_progress():
value = progressBar._user_value + 2.5
if value > progressBar._user_maximum:
value = -100 # progressBar._maximum / progressBar._upsampling_factor
progressBar.set_value(value)
timer = QTimer()
timer.timeout.connect(update_progress)
timer.start(200) # Update every half second
sys.exit(app.exec())
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.bec_progressbar.bec_progress_bar_plugin import BECProgressBarPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECProgressBarPlugin())
if __name__ == "__main__": # pragma: no cover
main()
+361 -124
View File
@@ -1,24 +1,24 @@
"""
BECConsole is a Qt widget that runs a Bash shell. The widget can be used and
embedded like any other Qt widget.
BECConsole is a Qt widget that runs a Bash shell.
BECConsole is powered by Pyte, a Python based terminal emulator
BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
"""
import collections
import fcntl
import html
import os
import pty
import subprocess
import sys
import threading
import pyte
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import QSize, QSocketNotifier, Qt
from qtpy.QtCore import Property as pyqtProperty
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QTextCursor
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
@@ -137,13 +137,52 @@ def QtKeyToAscii(event):
class Screen(pyte.HistoryScreen):
def __init__(self, stdin_fd, numColumns, numLines, historyLength):
super().__init__(numColumns, numLines, historyLength, ratio=1 / numLines)
def __init__(self, stdin_fd, cols, rows, historyLength):
super().__init__(cols, rows, historyLength, ratio=1 / rows)
self._fd = stdin_fd
def write_process_input(self, data):
"""Response to CPR request for example"""
os.write(self._fd, data.encode("utf-8"))
"""Response to CPR request (for example),
this can be for other requests
"""
try:
os.write(self._fd, data.encode("utf-8"))
except (IOError, OSError):
pass
def resize(self, lines, columns):
lines = lines or self.lines
columns = columns or self.columns
if lines == self.lines and columns == self.columns:
return # No changes.
self.dirty.clear()
self.dirty.update(range(lines))
self.save_cursor()
if lines < self.lines:
if lines <= self.cursor.y:
nlines_to_move_up = self.lines - lines
for i in range(nlines_to_move_up):
line = self.buffer[i] # .pop(0)
self.history.top.append(line)
self.cursor_position(0, 0)
self.delete_lines(nlines_to_move_up)
self.restore_cursor()
self.cursor.y -= nlines_to_move_up
else:
self.restore_cursor()
self.lines, self.columns = lines, columns
self.history = History(
self.history.top,
self.history.bottom,
1 / self.lines,
self.history.size,
self.history.position,
)
self.set_margins()
class Backend(QtCore.QObject):
@@ -155,17 +194,17 @@ class Backend(QtCore.QObject):
"""
# Signals to communicate with ``_TerminalWidget``.
startWork = pyqtSignal()
dataReady = pyqtSignal(object)
processExited = pyqtSignal()
def __init__(self, fd, numColumns, numLines):
def __init__(self, fd, cols, rows):
super().__init__()
# File descriptor that connects to Bash process.
self.fd = fd
# Setup Pyte (hard coded display size for now).
self.screen = Screen(self.fd, numColumns, numLines, 10000)
self.screen = Screen(self.fd, cols, rows, 10000)
self.stream = pyte.ByteStream()
self.stream.attach(self.screen)
@@ -174,12 +213,14 @@ class Backend(QtCore.QObject):
def _fd_readable(self):
"""
Poll the Bash output, run it through Pyte, and notify the main applet.
Poll the Bash output, run it through Pyte, and notify
"""
# Read the shell output until the file descriptor is closed.
try:
out = os.read(self.fd, 2**16)
except OSError:
self.processExited.emit()
self.notifier.setEnabled(False)
return
# Feed output into Pyte's state machine and send the new screen
@@ -188,93 +229,256 @@ class Backend(QtCore.QObject):
self.dataReady.emit(self.screen)
class BECConsole(QtWidgets.QScrollArea):
class BECConsole(QtWidgets.QWidget):
"""Container widget for the terminal text area"""
def __init__(self, parent=None, numLines=50, numColumns=125):
ICON_NAME = "terminal"
def __init__(self, parent=None, cols=132):
super().__init__(parent)
self.innerWidget = QtWidgets.QWidget(self)
QHBoxLayout(self.innerWidget)
self.innerWidget.layout().setContentsMargins(0, 0, 0, 0)
self.term = _TerminalWidget(self, cols, rows=43)
self.scroll_bar = QScrollBar(Qt.Vertical, self)
# self.scroll_bar.hide()
layout = QHBoxLayout(self)
layout.addWidget(self.term)
layout.addWidget(self.scroll_bar)
layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
layout.setContentsMargins(0, 0, 0, 0)
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)
self.term = _TerminalWidget(self.innerWidget, numLines, numColumns)
self.term.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.innerWidget.layout().addWidget(self.term)
pal = QPalette()
self.set_bgcolor(pal.window().color())
self.set_fgcolor(pal.windowText().color())
self.term.set_scroll_bar(self.scroll_bar)
self.set_cmd("bec --nogui")
self.scroll_bar = QScrollBar(Qt.Vertical, self.term)
self.innerWidget.layout().addWidget(self.scroll_bar)
self._check_designer_timer = QTimer()
self._check_designer_timer.timeout.connect(self.check_designer)
self._check_designer_timer.start(1000)
self.term.set_scroll(self.scroll_bar)
def minimumSizeHint(self):
size = self.term.sizeHint()
size.setWidth(size.width() + self.scroll_bar.width())
return size
self.setWidget(self.innerWidget)
def sizeHint(self):
return self.minimumSizeHint()
def start(self, cmd=["bec", "--nogui"], deactivate_ctrl_d=True):
def check_designer(self, calls={"n": 0}):
calls["n"] += 1
if self.term.fd is not None:
# already started
self._check_designer_timer.stop()
elif self.window().windowTitle().endswith("[Preview]"):
# assuming Designer preview -> start
self._check_designer_timer.stop()
self.term.start()
elif calls["n"] >= 3:
# assuming not in Designer -> stop checking
self._check_designer_timer.stop()
def get_rows(self):
return self.term.rows
def set_rows(self, rows):
self.term.rows = rows
self.adjustSize()
self.updateGeometry()
def get_cols(self):
return self.term.cols
def set_cols(self, cols):
self.term.cols = cols
self.adjustSize()
self.updateGeometry()
def get_bgcolor(self):
return QColor.fromString(self.term.bg_color)
def set_bgcolor(self, color):
self.term.bg_color = color.name(QColor.HexRgb)
def get_fgcolor(self):
return QColor.fromString(self.term.fg_color)
def set_fgcolor(self, color):
self.term.fg_color = color.name(QColor.HexRgb)
def get_cmd(self):
return self.term._cmd
def set_cmd(self, cmd):
self.term._cmd = cmd
if self.term.fd is None:
# not started yet
self.term.clear()
self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")
def start(self, deactivate_ctrl_d=True):
self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)
def push(self, text):
"""Push some text to the terminal"""
return self.term.push(text)
cols = pyqtProperty(int, get_cols, set_cols)
rows = pyqtProperty(int, get_rows, set_rows)
bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
cmd = pyqtProperty(str, get_cmd, set_cmd)
class _TerminalWidget(QtWidgets.QPlainTextEdit):
"""
Start ``Backend`` process and render Pyte output as text.
"""
def __init__(self, parent, numColumns=125, numLines=50, **kwargs):
super().__init__(parent)
def __init__(self, parent, cols=125, rows=50, **kwargs):
# file descriptor to communicate with the subprocess
self.fd = None
self.backend = None
self.lock = threading.Lock()
# command to execute
self._cmd = None
self._cmd = ""
# should ctrl-d be deactivated ? (prevent Python exit)
self._deactivate_ctrl_d = False
# Default colors
pal = QPalette()
self._fg_color = pal.text().color().name()
self._bg_color = pal.base().color().name()
# Specify the terminal size in terms of lines and columns.
self.numLines = numLines
self.numColumns = numColumns
self.output = [""] * numLines
self._rows = rows
self._cols = cols
self.output = collections.deque()
super().__init__(parent)
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)
# Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_bar = None
# Use Monospace fonts and disable line wrapping.
self.setFont(QtGui.QFont("Courier", 9))
self.setFont(QtGui.QFont("Monospace"))
self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
# Disable vertical scrollbar (we use our own, to be set via .set_scroll())
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
fmt = QtGui.QFontMetrics(self.font())
self._char_width = fmt.width("w")
self._char_height = fmt.height()
self.setCursorWidth(self._char_width)
# self.setStyleSheet("QPlainTextEdit { color: #ffff00; background-color: #303030; } ");
char_width = fmt.width("w")
self.setCursorWidth(char_width)
def start(self, deactivate_ctrl_d=False):
self.adjustSize()
self.updateGeometry()
self.update_stylesheet()
@property
def bg_color(self):
return self._bg_color
@bg_color.setter
def bg_color(self, hexcolor):
self._bg_color = hexcolor
self.update_stylesheet()
@property
def fg_color(self):
return self._fg_color
@fg_color.setter
def fg_color(self, hexcolor):
self._fg_color = hexcolor
self.update_stylesheet()
def update_stylesheet(self):
self.setStyleSheet(
f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
)
@property
def rows(self):
return self._rows
@rows.setter
def rows(self, rows: int):
if self.backend is None:
# not initialized yet, ok to change
self._rows = rows
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change rows after console is started.")
@property
def cols(self):
return self._cols
@cols.setter
def cols(self, cols: int):
if self.fd is None:
# not initialized yet, ok to change
self._cols = cols
self.adjustSize()
self.updateGeometry()
else:
raise RuntimeError("Cannot change cols after console is started.")
def start(self, deactivate_ctrl_d: bool = False):
self._deactivate_ctrl_d = deactivate_ctrl_d
# Start the Bash process
self.fd = self.forkShell()
self.update_term_size()
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.numColumns, self.numLines)
self.backend.dataReady.connect(self.dataReady)
# Start the Bash process
self.fd = self.fork_shell()
if self.fd:
# Create the ``Backend`` object
self.backend = Backend(self.fd, self.cols, self.rows)
self.backend.dataReady.connect(self.data_ready)
self.backend.processExited.connect(self.process_exited)
else:
self.process_exited()
def process_exited(self):
self.fd = None
self.clear()
self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
self.setReadOnly(True)
def data_ready(self, screen):
"""Handle new screen: redraw, set scroll bar max and slider, move cursor to its position
This method is triggered via a signal from ``Backend``.
"""
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def minimumSizeHint(self):
width = self._char_width * self.numColumns
height = self._char_height * self.numLines
return QSize(width, height + 20)
"""Return minimum size for current cols and rows"""
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
width = char_width * self.cols
height = char_height * self.rows
return QSize(width, height)
def set_scroll(self, scroll):
self.scroll = scroll
self.scroll.setMinimum(0)
self.scroll.valueChanged.connect(self.scroll_value_change)
def sizeHint(self):
return self.minimumSizeHint()
def scroll_value_change(self, value, old={"value": 0}):
def set_scroll_bar(self, scroll_bar):
self.scroll_bar = scroll_bar
self.scroll_bar.setMinimum(0)
self.scroll_bar.valueChanged.connect(self.scroll_value_change)
def scroll_value_change(self, value, old={"value": -1}):
if self.backend is None:
return
if old["value"] == -1:
old["value"] = self.scroll_bar.maximum()
if value <= old["value"]:
# scroll up
# value is number of lines from the start
@@ -288,13 +492,35 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
for i in range(nlines):
self.backend.screen.next_page()
old["value"] = value
self.dataReady(self.backend.screen, reset_scroll=False)
self.redraw_screen()
def adjust_scroll_bar(self):
sb = self.scroll_bar
sb.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
sb.setMaximum(tmp if tmp > 0 else 0)
sb.setSliderPosition(tmp if tmp > 0 else 0)
# if tmp > 0:
# # show scrollbar, but delayed - prevent recursion with widget size change
# QTimer.singleShot(0, scrollbar.show)
# else:
# QTimer.singleShot(0, scrollbar.hide)
sb.valueChanged.connect(self.scroll_value_change)
def write(self, data):
try:
os.write(self.fd, data)
except (IOError, OSError):
self.process_exited()
@Slot(object)
def keyPressEvent(self, event):
"""
Redirect all keystrokes to the terminal process.
"""
if self.fd is None:
# not started
return
# Convert the Qt key to the correct ASCII code.
if (
self._deactivate_ctrl_d
@@ -311,15 +537,17 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
# MacOS only: CMD-V handling
self._push_clipboard()
elif code is not None:
os.write(self.fd, code)
self.write(code)
def push(self, text):
"""
Write 'text' to terminal
"""
os.write(self.fd, text.encode("utf-8"))
self.write(text.encode("utf-8"))
def contextMenuEvent(self, event):
if self.fd is None:
return
menu = self.createStandardContextMenu()
for action in menu.actions():
# remove all actions except copy and paste
@@ -341,7 +569,20 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
clipboard = QApplication.instance().clipboard()
self.push(clipboard.text())
def move_cursor(self):
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
def mouseReleaseEvent(self, event):
if self.fd is None:
return
if event.button() == Qt.MiddleButton:
# push primary selection buffer ("mouse clipboard") to terminal
clipboard = QApplication.instance().clipboard()
@@ -355,34 +596,34 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
# mouse was used to select text -> nothing to do
pass
else:
# a simple 'click', make cursor going to end
textCursor.setPosition(0)
textCursor.movePosition(
QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
)
textCursor.movePosition(
QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
# a simple 'click', move scrollbar to end
self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
self.move_cursor()
return None
return super().mouseReleaseEvent(event)
def dataReady(self, screenData, reset_scroll=True):
def redraw_screen(self):
"""
Render the new screen as text into the widget.
Render the screen as formatted text into the widget.
"""
screen = self.backend.screen
This method is triggered via a signal from ``Backend``.
"""
with self.lock:
# Clear the widget
# Clear the widget
if screen.dirty:
self.clear()
while len(self.output) < (max(screen.dirty) + 1):
self.output.append("")
while len(self.output) > (max(screen.dirty) + 1):
self.output.pop()
# Prepare the HTML output
for line_no in screenData.dirty:
for line_no in screen.dirty:
line = text = ""
style = old_style = ""
for ch in screenData.buffer[line_no].values():
old_idx = 0
for idx, ch in screen.buffer[line_no].items():
text += " " * (idx - old_idx - 1)
old_idx = idx
style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
if style != old_style:
if old_style:
@@ -396,45 +637,47 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
else:
line += html.escape(text, quote=True)
# do a check at the cursor position:
# it is possible x pos > output line length,
# for example if last escape codes are "cursor forward" past end of text,
# like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
# In this case, cursor is at 12 but last text output is at 8 -> insert spaces
if line_no == screen.cursor.y:
llen = len(screen.buffer[line_no])
if llen < screen.cursor.x:
line += " " * (screen.cursor.x - llen)
self.output[line_no] = line
# fill the text area with HTML contents in one go
self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")
# done updates, all clean
screenData.dirty.clear()
# did updates, all clean
screen.dirty.clear()
# Activate cursor
textCursor = self.textCursor()
textCursor.setPosition(0)
textCursor.movePosition(QTextCursor.Down, QTextCursor.MoveAnchor, screenData.cursor.y)
textCursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, screenData.cursor.x)
self.setTextCursor(textCursor)
self.ensureCursorVisible()
def update_term_size(self):
fmt = QtGui.QFontMetrics(self.font())
char_width = fmt.width("w")
char_height = fmt.height()
self._cols = int(self.width() / char_width)
self._rows = int(self.height() / char_height)
# manage scroll
if reset_scroll:
self.scroll.valueChanged.disconnect(self.scroll_value_change)
tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
self.scroll.setMaximum(tmp if tmp > 0 else 0)
self.scroll.setSliderPosition(len(self.backend.screen.history.top))
self.scroll.valueChanged.connect(self.scroll_value_change)
# def resizeEvent(self, event):
# with self.lock:
# self.numColumns = int(self.width() / self._char_width)
# self.numLines = int(self.height() / self._char_height)
# self.output = [""] * self.numLines
# print("RESIZING TO", self.numColumns, "x", self.numLines)
# self.backend.screen.resize(self.numLines, self.numColumns)
def resizeEvent(self, event):
self.update_term_size()
if self.fd:
self.backend.screen.resize(self._rows, self._cols)
self.redraw_screen()
self.adjust_scroll_bar()
self.move_cursor()
def wheelEvent(self, event):
if not self.fd:
return
y = event.angleDelta().y()
if y > 0:
self.backend.screen.prev_page()
else:
self.backend.screen.next_page()
self.dataReady(self.backend.screen, reset_scroll=False)
self.redraw_screen()
def forkShell(self):
def fork_shell(self):
"""
Fork the current process and execute bec in shell.
"""
@@ -443,32 +686,30 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit):
except (IOError, OSError):
return False
if pid == 0:
# Safe way to make it work under BSD and Linux
try:
ls = os.environ["LANG"].split(".")
except KeyError:
ls = []
if len(ls) < 2:
ls = ["en_US", "UTF-8"]
os.putenv("COLUMNS", str(self.cols))
os.putenv("LINES", str(self.rows))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if not self._cmd:
self._cmd = os.environ["SHELL"]
cmd = self._cmd
if isinstance(cmd, str):
cmd = cmd.split()
try:
os.putenv("COLUMNS", str(self.numColumns))
os.putenv("LINES", str(self.numLines))
os.putenv("TERM", "linux")
os.putenv("LANG", ls[0] + ".UTF-8")
if isinstance(self._cmd, str):
os.execvp(self._cmd, self._cmd)
else:
os.execvp(self._cmd[0], self._cmd)
# print "child_pid", child_pid, sts
os.execvp(cmd[0], cmd)
except (IOError, OSError):
pass
# self.proc_finish(sid)
os._exit(0)
else:
# We are in the parent process.
# Set file control
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
print("Spawned Bash shell (PID {})".format(pid))
return fd
@@ -478,17 +719,13 @@ if __name__ == "__main__":
from qtpy import QtGui, QtWidgets
# Terminal size in characters.
numLines = 25
numColumns = 100
# Create the Qt application and QBash instance.
# Create the Qt application and console.
app = QtWidgets.QApplication([])
mainwin = QtWidgets.QMainWindow()
title = "BECConsole ({}x{})".format(numColumns, numLines)
title = "BECConsole"
mainwin.setWindowTitle(title)
console = BECConsole(mainwin, numColumns, numLines)
console = BECConsole(mainwin)
mainwin.setCentralWidget(console)
console.start()
@@ -0,0 +1 @@
{'files': ['console.py']}
@@ -0,0 +1,58 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.console.console import BECConsole
DOM_XML = """
<ui language='c++'>
<widget class='BECConsole' name='bec_console'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECConsole(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Console"
def icon(self):
return designer_material_icon(BECConsole.ICON_NAME)
def includeFile(self):
return "bec_console"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "BECConsole"
def toolTip(self):
return "A terminal-like vt100 widget."
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.console.console_plugin import BECConsolePlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -165,21 +165,18 @@ class DapComboBox(BECWidget, QWidget):
return True
# pragma: no cover
def main():
"""Main function to run the DapComboBox widget."""
import sys
if __name__ == "__main__": # pragma: no cover
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
set_theme("auto")
widget = DapComboBox()
app = QApplication([])
set_theme("dark")
widget = QWidget()
widget.setFixedSize(200, 200)
layout = QVBoxLayout()
widget.setLayout(layout)
layout.addWidget(DapComboBox())
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
app.exec_()
@@ -60,7 +60,7 @@ class DarkModeButton(BECWidget, QWidget):
bool: True if dark mode is enabled, False otherwise.
"""
qapp = QApplication.instance()
if hasattr(qapp, "theme") and qapp.theme["theme"] == "dark":
if hasattr(qapp, "theme") and qapp.theme.theme == "dark":
return True
return False
@@ -31,6 +31,7 @@ class AxisSettings(SettingWidget):
# Top Box
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
self.ui.switch_outer_axes.checked = axis_config["outer_axes"]
# X Axis Box
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
@@ -63,6 +64,7 @@ class AxisSettings(SettingWidget):
@Slot()
def accept_changes(self):
title = WidgetIO.get_value(self.ui.plot_title)
outer_axes = self.ui.switch_outer_axes.checked
# X Axis
x_label = WidgetIO.get_value(self.ui.x_label)
@@ -86,3 +88,4 @@ class AxisSettings(SettingWidget):
y_lim=y_lim,
)
self.target_widget.set_grid(x_grid, y_grid)
self.target_widget.set_outer_axes(outer_axes)
@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>417</width>
<height>250</height>
<width>427</width>
<height>270</height>
</rect>
</property>
<property name="minimumSize">
@@ -26,7 +26,28 @@
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="1">
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
@@ -120,7 +141,7 @@
</layout>
</widget>
</item>
<item row="1" column="0">
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
@@ -214,22 +235,22 @@
</layout>
</widget>
</item>
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
+15 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections import defaultdict
from typing import Literal, Optional
import bec_qthemes
@@ -31,6 +32,7 @@ class AxisConfig(BaseModel):
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
x_grid: bool = Field(False, description="Show grid on the x-axis.")
y_grid: bool = Field(False, description="Show grid on the y-axis.")
outer_axes: bool = Field(False, description="Show the outer axes of the plot widget.")
model_config: dict = {"validate_assignment": True}
@@ -75,6 +77,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
"set_x_lim",
"set_y_lim",
"set_grid",
"set_outer_axes",
"lock_aspect_ratio",
"export",
"remove",
@@ -116,7 +119,7 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme["theme"]
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
@@ -336,6 +339,17 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def set_outer_axes(self, show: bool = True):
"""
Set the outer axes of the plot widget.
Args:
show(bool): Show the outer axes.
"""
self.plot_item.showAxis("top", show)
self.plot_item.showAxis("right", show)
self.config.axis.outer_axes = show
def add_legend(self):
"""Add legend to the plot"""
self.plot_item.addLegend()
@@ -12,10 +12,12 @@ from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform_curve import (
BECCurve,
@@ -74,6 +76,8 @@ class BECWaveform(BECPlotBase):
"remove",
"clear_all",
"set_legend_label_size",
"toggle_roi",
"select_roi",
]
scan_signal_update = pyqtSignal()
async_signal_update = pyqtSignal()
@@ -81,6 +85,9 @@ class BECWaveform(BECPlotBase):
dap_summary_update = pyqtSignal(dict, dict)
autorange_signal = pyqtSignal()
new_scan = pyqtSignal()
roi_changed = pyqtSignal(tuple)
roi_active = pyqtSignal(bool)
request_dap_refresh = pyqtSignal()
def __init__(
self,
@@ -100,6 +107,9 @@ class BECWaveform(BECPlotBase):
self.old_scan_id = None
self.scan_id = None
self.scan_item = None
self._roi_region = None
self.roi_select = None
self._accent_colors = get_accent_colors()
self._x_axis_mode = {
"name": None,
"entry": None,
@@ -130,6 +140,63 @@ class BECWaveform(BECPlotBase):
self.add_legend()
self.apply_config(self.config)
@Slot(bool)
def toggle_roi(self, toggled: bool) -> None:
"""Toggle the linear region selector on the plot.
Args:
toggled(bool): If True, enable the linear region selector.
"""
if toggled:
return self._hook_roi()
return self._unhook_roi()
@Slot(tuple)
def select_roi(self, region: tuple[float, float]):
"""Set the fit region of the plot widget. At the moment only a single region is supported.
To remove the roi region again, use toggle_roi_region
Args:
region(tuple[float, float]): The fit region.
"""
if self.roi_region == (None, None):
self.toggle_roi(True)
try:
self.roi_select.linear_region_selector.setRegion(region)
except Exception as e:
logger.error(f"Error setting region {tuple}; Exception raised: {e}")
raise ValueError(f"Error setting region {tuple}; Exception raised: {e}")
def _hook_roi(self):
"""Hook the linear region selector to the plot."""
color = self._accent_colors.default
color.setAlpha(int(0.2 * 255))
hover_color = self._accent_colors.default
hover_color.setAlpha(int(0.35 * 255))
if self.roi_select is None:
self.roi_select = LinearRegionWrapper(
self.plot_item, color=color, hover_color=hover_color, parent=self
)
self.roi_select.add_region_selector()
self.roi_select.region_changed.connect(self.roi_changed)
self.roi_select.region_changed.connect(self.set_roi_region)
self.request_dap_refresh.connect(self.refresh_dap)
self._emit_roi_region()
self.roi_active.emit(True)
def _unhook_roi(self):
"""Unhook the linear region selector from the plot."""
if self.roi_select is not None:
self.roi_select.region_changed.disconnect(self.roi_changed)
self.roi_select.region_changed.disconnect(self.set_roi_region)
self.request_dap_refresh.disconnect(self.refresh_dap)
self.roi_active.emit(False)
self.roi_region = None
self.refresh_dap()
self.roi_select.cleanup()
self.roi_select.deleteLater()
self.roi_select = None
def apply_config(self, config: dict | SubplotConfig, replot_last_scan: bool = False):
"""
Apply the configuration to the 1D waveform widget.
@@ -171,6 +238,48 @@ class BECWaveform(BECPlotBase):
for curve in self.curves:
curve.config.parent_id = new_gui_id
###################################
# Fit Range Properties
###################################
@property
def roi_region(self) -> tuple[float, float] | None:
"""
Get the fit region of the plot widget.
Returns:
tuple: The fit region.
"""
if self._roi_region is not None:
return self._roi_region
return None, None
@roi_region.setter
def roi_region(self, value: tuple[float, float] | None):
"""Set the fit region of the plot widget.
Args:
value(tuple[float, float]|None): The fit region.
"""
self._roi_region = value
if value is not None:
self.request_dap_refresh.emit()
@Slot(tuple)
def set_roi_region(self, region: tuple[float, float]):
"""
Set the fit region of the plot widget.
Args:
region(tuple[float, float]): The fit region.
"""
self.roi_region = region
def _emit_roi_region(self):
"""Emit the current ROI from selector the plot widget."""
if self.roi_select is not None:
self.set_roi_region(self.roi_select.linear_region_selector.getRegion())
###################################
# Waveform Properties
###################################
@@ -1058,13 +1167,14 @@ class BECWaveform(BECPlotBase):
y_entry = curve.config.signals.y.entry
model_name = curve.config.signals.dap
model = getattr(self.dap, model_name)
x_min, x_max = self.roi_region
msg = messages.DAPRequestMessage(
dap_cls="LmfitService1D",
dap_type="on_demand",
config={
"args": [self.scan_id, x_name, x_entry, y_name, y_entry],
"kwargs": {},
"kwargs": {"x_min": x_min, "x_max": x_max},
"class_args": model._plugin_info["class_args"],
"class_kwargs": model._plugin_info["class_kwargs"],
},
@@ -1304,7 +1414,8 @@ class BECWaveform(BECPlotBase):
self.scan_signal_update.emit()
self.async_signal_update.emit()
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
# pylint: ignore: undefined-variable
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict: # | pd.DataFrame:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
@@ -1351,13 +1462,21 @@ class BECWaveform(BECPlotBase):
"""
MatplotlibExporter(self.plot_item).export()
def clear_all(self):
def clear_source(self, source: Literal["DAP", "async", "scan_segment", "custom"]):
"""Clear speicific source from self._curves_data.
Args:
source (Literal["DAP", "async", "scan_segment", "custom"]): Source to be cleared.
"""
curves_data = self._curves_data
sources = list(curves_data.keys())
curve_ids_to_remove = list(curves_data[source].keys())
for curve_id in curve_ids_to_remove:
self.remove_curve(curve_id)
def clear_all(self):
sources = list(self._curves_data.keys())
for source in sources:
curve_ids_to_remove = list(curves_data[source].keys())
for curve_id in curve_ids_to_remove:
self.remove_curve(curve_id)
self.clear_source(source)
def cleanup(self):
"""Cleanup the widget connection from BECDispatcher."""
@@ -1,67 +1,285 @@
from qtpy.QtCore import Qt, Slot
from qtpy.QtGui import QPainter, QPen
import numpy as np
from qtpy.QtCore import Property, QSize, Qt, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(QWidget):
class PositionIndicator(BECWidget, QWidget):
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None):
super().__init__(parent)
self.position = 0.5
def __init__(self, parent=None, client=None, config=None, gui_id=None):
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.position = 50
self.min_value = 0
self.max_value = 100
self.scaling_factor = 0.5
self.setMinimumHeight(10)
self.is_vertical = False
self._current_indicator_position = 0
self._draw_position = 0
self._rounded_corners = 10
self._indicator_width = 2
self._indicator_color = get_accent_colors().success
self._background_color = get_theme_palette().mid().color()
self._use_color_palette = True
def set_range(self, min_value, max_value):
def set_range(self, min_value: float, max_value: float):
"""
Set the range of the position indicator
Args:
min_value(float): Minimum value of the range
max_value(float): Maximum value of the range
"""
self.minimum = min_value
self.maximum = max_value
@Property(float)
def minimum(self):
"""
Property to get the minimum value of the position indicator
"""
return self.min_value
@minimum.setter
def minimum(self, min_value: float):
"""
Setter for the minimum property
Args:
min_value: The minimum value of the position indicator
"""
self.min_value = min_value
self.max_value = max_value
self.update()
@Property(float)
def maximum(self):
"""
Property to get the maximum value of the position indicator
"""
return self.max_value
@maximum.setter
def maximum(self, max_value: float):
"""
Setter for the maximum property
Args:
max_value: The maximum value of the position indicator
"""
self.max_value = max_value
self.update()
@Property(bool)
def vertical(self):
"""
Property to determine the orientation of the position indicator
"""
return self.is_vertical
@vertical.setter
def vertical(self, is_vertical: bool):
"""
Setter for the vertical property
Args:
is_vertical: True if the indicator should be vertical, False if horizontal
"""
self.is_vertical = is_vertical
self.update()
@Property(float)
def value(self):
"""
Property to get the current value of the position indicator
"""
return self.position
@value.setter
def value(self, position: float):
"""
Setter for the value property
Args:
position: The new position of the indicator
"""
self.set_value(position)
@Property(int)
def indicator_width(self):
"""
Property to get the width of the indicator
"""
return self._indicator_width
@indicator_width.setter
def indicator_width(self, width: int):
"""
Setter for the indicator width property
Args:
width: The new width of the indicator
"""
self._indicator_width = width
self.update()
@Property(int)
def rounded_corners(self):
"""
Property to get the rounded corners of the position indicator
"""
return self._rounded_corners
@rounded_corners.setter
def rounded_corners(self, value: int):
"""
Setter for the rounded corners property
Args:
value: The new value for the rounded corners
"""
self._rounded_corners = value
self.update()
@Property(QColor)
def indicator_color(self):
"""
Property to get the color of the indicator
"""
return self._indicator_color
@indicator_color.setter
def indicator_color(self, color: QColor):
"""
Setter for the indicator color property
Args:
color: The new color for the indicator
"""
self._indicator_color = color
self.update()
@Property(QColor)
def background_color(self):
"""
Property to get the background color of the position indicator
"""
return self._background_color
@background_color.setter
def background_color(self, color: QColor):
"""
Setter for the background color property
Args:
color: The new background color
"""
self._background_color = color
self.update()
@Property(bool)
def use_color_palette(self):
"""
Property to determine if the indicator should use the color palette or the custom color.
"""
return self._use_color_palette
@use_color_palette.setter
def use_color_palette(self, use_palette: bool):
"""
Setter for the use color palette property
Args:
use_palette: True if the indicator should use the color palette, False if custom color
"""
self._use_color_palette = use_palette
self.update()
# @Property(float)
@Slot(int)
@Slot(float)
def on_position_update(self, position: float):
def set_value(self, position: float):
self.position = position
self.update()
def _get_indicator_color(self):
if self._use_color_palette:
return get_accent_colors().success
return self._indicator_color
def _get_background_brush(self):
if self._use_color_palette:
return get_theme_palette().mid()
return QBrush(self._background_color)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
width = self.width()
height = self.height()
# Draw horizontal line
painter.setPen(Qt.black)
painter.drawLine(0, height // 2, width, height // 2)
# Set up the brush for the background
painter.setBrush(self._get_background_brush())
# Draw shorter vertical line at the current position
x_pos = int(self.position * width)
painter.setPen(QPen(Qt.red, 2))
short_line_height = int(height * self.scaling_factor)
painter.drawLine(
x_pos,
(height // 2) - (short_line_height // 2),
x_pos,
(height // 2) + (short_line_height // 2),
# Create a QPainterPath with a rounded rectangle for clipping
path = QPainterPath()
path.addRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners)
# Set clipping to the rounded rectangle
painter.setClipPath(path)
# Draw the rounded rectangle background first
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners)
# get the position scaled to the defined min and max values
self._current_indicator_position = position = np.interp(
self.position, [self.min_value, self.max_value], [0, 100]
)
# Draw thicker vertical lines at the ends
end_line_pen = QPen(Qt.blue, 5)
painter.setPen(end_line_pen)
painter.drawLine(0, 0, 0, height)
painter.drawLine(width - 1, 0, width - 1, height)
if self.is_vertical:
# If vertical, rotate the coordinate system by -90 degrees
painter.translate(width // 2, height // 2) # Move origin to center
painter.rotate(-90) # Rotate by -90 degrees for vertical drawing
painter.translate(-height // 2, -width // 2) # Restore the origin for drawing
# Switch width and height for the vertical orientation
width, height = height, width
# Draw the moving vertical indicator, respecting the clip path
self._draw_position = x_pos = round(
position * width / 100
) # Position for the vertical line
indicator_pen = QPen(self._get_indicator_color(), self._indicator_width)
painter.setPen(indicator_pen)
painter.drawLine(x_pos, 0, x_pos, height)
painter.end()
def minimumSizeHint(self):
# Set the smallest possible size
return QSize(10, 10)
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover
from bec_qthemes import setup_theme
from qtpy.QtWidgets import QApplication, QSlider, QVBoxLayout
app = QApplication([])
setup_theme("dark")
# Create position indicator and slider
position_indicator = PositionIndicator()
# position_indicator.set_range(0, 1)
slider = QSlider(Qt.Horizontal)
slider.valueChanged.connect(lambda value: position_indicator.on_position_update(value / 100))
slider.valueChanged.connect(lambda value: position_indicator.set_value(value))
position_indicator.is_vertical = False
# position_indicator.set_value(100)
layout = QVBoxLayout()
layout.addWidget(position_indicator)
layout.addWidget(slider)
@@ -16,7 +16,7 @@ from qtpy.QtWidgets import QDialog, QDoubleSpinBox, QPushButton, QVBoxLayout, QW
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
logger = bec_logger.logger
@@ -73,6 +73,10 @@ class PositionerBox(BECWidget, QWidget):
self.ui.step_size.setStepType(QDoubleSpinBox.AdaptiveDecimalStepType)
self.ui.stop.clicked.connect(self.on_stop)
self.ui.stop.setToolTip("Stop")
self.ui.stop.setStyleSheet(
f"QPushButton {{background-color: {get_accent_colors().emergency.name()}; color: white;}}"
)
self.ui.tweak_right.clicked.connect(self.on_tweak_right)
self.ui.tweak_right.setToolTip("Tweak right")
self.ui.tweak_left.clicked.connect(self.on_tweak_left)
@@ -249,7 +253,7 @@ class PositionerBox(BECWidget, QWidget):
self.update_limits(limits)
if limits is not None and readback_val is not None and limits[0] != limits[1]:
pos = (readback_val - limits[0]) / (limits[1] - limits[0])
self.ui.position_indicator.on_position_update(pos)
self.ui.position_indicator.set_value(pos)
def update_limits(self, limits: tuple):
"""Update limits
@@ -170,7 +170,20 @@
</layout>
</item>
<item>
<widget class="PositionIndicator" name="position_indicator"/>
<widget class="PositionIndicator" name="position_indicator">
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="indicator_width" stdset="0">
<number>4</number>
</property>
<property name="rounded_corners" stdset="0">
<number>4</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="readback">
@@ -24,11 +24,9 @@ class PositionerControlLine(PositionerBox):
if __name__ == "__main__": # pragma: no cover
import sys
import qdarktheme
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
qdarktheme.setup_theme("dark")
widget = PositionerControlLine(device="samy")
widget.show()
@@ -6,7 +6,7 @@
<rect>
<x>0</x>
<y>0</y>
<width>785</width>
<width>612</width>
<height>91</height>
</rect>
</property>
@@ -59,16 +59,6 @@
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="PositionIndicator" name="position_indicator">
<property name="minimumSize">
<size>
<width>80</width>
<height>10</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget">
<property name="minimumSize">
@@ -139,14 +129,14 @@
<widget class="QToolButton" name="tweak_left">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
@@ -154,8 +144,8 @@
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="arrowType">
@@ -170,14 +160,14 @@
<widget class="QToolButton" name="tweak_right">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
@@ -185,8 +175,8 @@
</property>
<property name="iconSize">
<size>
<width>30</width>
<height>30</height>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="arrowType">
@@ -194,6 +184,34 @@
</property>
</widget>
</item>
<item>
<widget class="PositionIndicator" name="position_indicator">
<property name="minimumSize">
<size>
<width>15</width>
<height>10</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>15</width>
<height>16777215</height>
</size>
</property>
<property name="maximum" stdset="0">
<double>1.000000000000000</double>
</property>
<property name="vertical" stdset="0">
<bool>true</bool>
</property>
<property name="value" stdset="0">
<double>0.500000000000000</double>
</property>
<property name="rounded_corners" stdset="0">
<number>2</number>
</property>
</widget>
</item>
</layout>
</widget>
</item>
+82 -76
View File
@@ -1,39 +1,47 @@
import re
"""Module for a text box widget that displays text in plain and HTML format and adheres to the BECWidget interface & style."""
from pydantic import Field, field_validator
from qtpy.QtWidgets import QTextEdit
import re
from html.parser import HTMLParser
from bec_lib.logger import bec_logger
from pydantic import Field
from qtpy.QtCore import Property, Slot
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
logger = bec_logger.logger
DEFAULT_TEXT = "<h1>Welcome to the BEC Widget TextBox</h1><p>A widget that allows user to display text in plain and HTML format.</p><p>This is an example of displaying HTML text.</p>"
class TextBoxConfig(ConnectionConfig):
"""Configuration for the TextBox widget.
theme: str = Field("dark", description="The theme of the figure widget.")
font_color: str = Field("#FFF", description="The font color of the text")
background_color: str = Field("#000", description="The background color of the widget.")
font_size: int = Field(16, description="The font size of the text in the widget.")
text: str = Field("", description="The text to display in the widget.")
Args:
text (str, optional): The text to display in the widget. Defaults to None.
is_html (bool, optional): Whether the text is in HTML format or not. Defaults to False.
"""
@classmethod
@field_validator("theme")
def validate_theme(cls, v):
"""Validate the theme of the figure widget."""
if v not in ["dark", "light"]:
raise ValueError("Theme must be either 'dark' or 'light'")
return v
_validate_font_color = field_validator("font_color")(Colors.validate_color)
_validate_background_color = field_validator("background_color")(Colors.validate_color)
text: str | None = Field(None, description="The text to display in the widget.")
is_html: bool = Field(False, description="Whether the text is in HTML format or not.")
class TextBox(BECWidget, QTextEdit):
class TextBox(BECWidget, QWidget):
"""A widget that displays text in plain and HTML format
USER_ACCESS = ["set_color", "set_text", "set_font_size"]
Args:
parent (QWidget, optional): The parent widget. Defaults to None.
client ([type], optional): The client to use. Defaults to None.
config ([type], optional): The config to use. Defaults to None.
gui_id ([type], optional): The gui_id to use. Defaults to None.
"""
USER_ACCESS = ["set_plain_text", "set_html_text"]
ICON_NAME = "chat"
def __init__(self, parent=None, text: str = "", client=None, config=None, gui_id=None):
def __init__(self, parent=None, client=None, config=None, gui_id=None):
if config is None:
config = TextBoxConfig(widget_class=self.__class__.__name__)
else:
@@ -41,80 +49,79 @@ class TextBox(BECWidget, QTextEdit):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
QTextEdit.__init__(self, parent=parent)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.text_box_text_edit = QTextEdit(parent=self)
self.layout.addWidget(self.text_box_text_edit)
self.setLayout(self.layout)
self.layout.setContentsMargins(0, 0, 0, 0)
self.config = config
self.setReadOnly(True)
self.setGeometry(self.rect())
self.set_color(self.config.background_color, self.config.font_color)
if not text:
text = "<h1>Welcome to the BEC Widget TextBox</h1><p>A widget that allows user to display text in plain and HTML format.</p><p>This is an example of displaying HTML text.</p>"
self.set_text(text)
def change_theme(self) -> None:
"""
Change the theme of the figure widget.
"""
if self.config.theme == "dark":
theme = "light"
font_color = "#000"
background_color = "#FFF"
self.text_box_text_edit.setReadOnly(True)
if self.config.text is not None:
if self.config.is_html:
self.set_html_text(self.config.text)
else:
self.set_plain_text(self.config.text)
else:
theme = "dark"
font_color = "#FFF"
background_color = "#000"
self.config.theme = theme
self.set_color(background_color, font_color)
self.set_html_text(DEFAULT_TEXT)
def set_color(self, background_color: str, font_color: str) -> None:
"""Set the background color of the widget.
@Slot(str)
def set_plain_text(self, text: str) -> None:
"""Set the plain text of the widget.
Args:
background_color (str): The color to set the background in HEX.
font_color (str): The color to set the font in HEX.
text (str): The text to set.
"""
self.config.background_color = background_color
self.config.font_color = font_color
self._update_stylesheet()
self.text_box_text_edit.setPlainText(text)
self.config.text = text
self.config.is_html = False
def set_font_size(self, size: int) -> None:
"""Set the font size of the text in the widget.
@Slot(str)
def set_html_text(self, text: str) -> None:
"""Set the HTML text of the widget.
Args:
size (int): The font size to set.
text (str): The text to set.
"""
self.config.font_size = size
self._update_stylesheet()
self.text_box_text_edit.setHtml(text)
self.config.text = text
self.config.is_html = True
def _update_stylesheet(self):
"""Update the stylesheet of the widget."""
self.setStyleSheet(
f"background-color: {self.config.background_color}; color: {self.config.font_color}; font-size: {self.config.font_size}px"
)
@Property(str)
def plain_text(self) -> str:
"""Get the text of the widget.
def set_text(self, text: str) -> None:
Returns:
str: The text of the widget.
"""
return self.text_box_text_edit.toPlainText()
@plain_text.setter
def plain_text(self, text: str) -> None:
"""Set the text of the widget.
Args:
text (str): The text to set.
"""
if self.is_html(text):
self.setHtml(text)
else:
self.setPlainText(text)
self.config.text = text
self.set_plain_text(text)
def is_html(self, text: str) -> bool:
"""Check if the text contains HTML tags.
Args:
text (str): The text to check.
@Property(str)
def html_text(self) -> str:
"""Get the HTML text of the widget.
Returns:
bool: True if the text contains HTML tags, False otherwise.
str: The HTML text of the widget.
"""
return bool(re.search(r"<[a-zA-Z/][^>]*>", text))
return self.text_box_text_edit.toHtml()
@html_text.setter
def html_text(self, text: str) -> None:
"""Set the HTML text of the widget.
Args:
text (str): The HTML text to set.
"""
self.set_html_text(text)
if __name__ == "__main__":
@@ -123,7 +130,6 @@ if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = TextBox()
widget.show()
sys.exit(app.exec())
@@ -13,6 +13,7 @@ from bec_widgets.qt_utils.error_popups import WarningPopupUtility
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import Colors, UILoader
from bec_widgets.widgets.color_button.color_button import ColorButton
from bec_widgets.widgets.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@@ -241,8 +242,9 @@ class DialogRow(QObject):
self.device_line_edit = DeviceLineEdit()
self.entry_line_edit = QLineEdit()
self.dap_combo = QComboBox()
self.populate_dap_combobox()
self.dap_combo = DapComboBox()
self.dap_combo.populate_fit_model_combobox()
self.dap_combo.select_fit_model("GaussianModel")
# Styling
self.color_button = ColorButton()
@@ -261,16 +263,6 @@ class DialogRow(QObject):
lambda: self.remove_row()
) # From some reason do not work without lambda
def populate_dap_combobox(self):
available_models = [
attr
for attr in dir(self.client.dap)
if not attr.startswith("__")
and not callable(getattr(self.client.dap, attr))
and not attr.startswith("_")
]
self.dap_combo.addItems(available_models)
def add_scan_row(self):
if self.config is not None:
self.device_line_edit.setText(self.config.signals.y.name)
@@ -298,7 +290,7 @@ class DialogRow(QObject):
if self.config is not None:
self.device_line_edit.setText(self.config.signals.y.name)
self.entry_line_edit.setText(self.config.signals.y.entry)
self.dap_combo.setCurrentText(self.config.signals.dap)
self.dap_combo.fit_model_combobox.setCurrentText(self.config.signals.dap)
self.color_button.set_color(self.config.color)
self.style_combo.setCurrentText(self.config.pen_style)
self.width.setValue(self.config.pen_width)
@@ -312,7 +304,7 @@ class DialogRow(QObject):
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
self.table_widget.setCellWidget(self.row, 2, self.dap_combo)
self.table_widget.setCellWidget(self.row, 2, self.dap_combo.fit_model_combobox)
self.table_widget.setCellWidget(self.row, 3, self.color_button)
self.table_widget.setCellWidget(self.row, 4, self.style_combo)
self.table_widget.setCellWidget(self.row, 5, self.width)
@@ -6,7 +6,7 @@ from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
@@ -55,6 +55,8 @@ class BECWaveformWidget(BECWidget, QWidget):
"lock_aspect_ratio",
"export",
"export_to_matplotlib",
"toggle_roi",
"select_roi",
]
scan_signal_update = Signal()
async_signal_update = Signal()
@@ -70,6 +72,8 @@ class BECWaveformWidget(BECWidget, QWidget):
crosshair_coordinates_changed_string = Signal(str)
crosshair_coordinates_clicked = Signal(tuple)
crosshair_coordinates_clicked_string = Signal(str)
roi_changed = Signal(tuple)
roi_active = Signal(bool)
def __init__(
self,
@@ -120,6 +124,11 @@ class BECWaveformWidget(BECWidget, QWidget):
"crosshair": MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
),
"roi_select": MaterialIconAction(
icon_name="align_justify_space_between",
tooltip="Add ROI region for DAP",
checkable=True,
),
},
target_widget=self,
)
@@ -133,6 +142,7 @@ class BECWaveformWidget(BECWidget, QWidget):
self.waveform.apply_config(config)
self.config = config
self._clear_curves_on_plot_update = False
self.hook_waveform_signals()
self._hook_actions()
@@ -160,6 +170,8 @@ class BECWaveformWidget(BECWidget, QWidget):
self.waveform.crosshair_position_clicked.connect(
self._emit_crosshair_position_clicked_string
)
self.waveform.roi_changed.connect(self.roi_changed)
self.waveform.roi_active.connect(self.roi_active)
def _hook_actions(self):
self.toolbar.widgets["save"].action.triggered.connect(self.export)
@@ -173,6 +185,7 @@ class BECWaveformWidget(BECWidget, QWidget):
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_fit_summary_dialog)
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
self.toolbar.widgets["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair)
self.toolbar.widgets["roi_select"].action.toggled.connect(self.waveform.toggle_roi)
# self.toolbar.widgets["import"].action.triggered.connect(
# lambda: self.load_config(path=None, gui=True)
# )
@@ -180,6 +193,29 @@ class BECWaveformWidget(BECWidget, QWidget):
# lambda: self.save_config(path=None, gui=True)
# )
@Slot(bool)
def toogle_roi_select(self, checked: bool):
"""Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
self.toolbar.widgets["roi_select"].action.setChecked(checked)
@Property(bool)
def clear_curves_on_plot_update(self) -> bool:
"""If True, clear curves on plot update."""
return self._clear_curves_on_plot_update
@clear_curves_on_plot_update.setter
def clear_curves_on_plot_update(self, value: bool):
"""Set the clear curves on plot update property.
Args:
value(bool): If True, clear curves on plot update.
"""
self._clear_curves_on_plot_update = value
@SafeSlot(tuple)
def _emit_crosshair_coordinates_changed_string(self, coordinates):
self.crosshair_coordinates_changed_string.emit(str(coordinates))
@@ -260,7 +296,8 @@ class BECWaveformWidget(BECWidget, QWidget):
"""
self.waveform.set_colormap(colormap)
@SafeSlot(str, popup_error=True)
@Slot(str, str) # Slot for x_name, x_entry
@SafeSlot(str, popup_error=True) # Slot for x_name and
def set_x(self, x_name: str, x_entry: str | None = None):
"""
Change the x axis of the plot widget.
@@ -275,7 +312,8 @@ class BECWaveformWidget(BECWidget, QWidget):
"""
self.waveform.set_x(x_name, x_entry)
@SafeSlot(str, popup_error=True)
@Slot(str) # Slot for y_name
@SafeSlot(popup_error=True)
def plot(
self,
arg1: list | np.ndarray | str | None = None,
@@ -315,7 +353,8 @@ class BECWaveformWidget(BECWidget, QWidget):
Returns:
BECCurve: The curve object.
"""
# self._check_if_scans_have_same_x(enabled=True, x_name_to_check=x_name)
if self.clear_curves_on_plot_update is True:
self.waveform.clear_source(source="scan_segment")
return self.waveform.plot(
arg1=arg1,
x=x,
@@ -334,6 +373,9 @@ class BECWaveformWidget(BECWidget, QWidget):
**kwargs,
)
@Slot(
str, str, str, str, str, str, bool
) # Slot for x_name, y_name, x_entry, y_entry, color, validate_bec
@SafeSlot(str, str, str, popup_error=True)
def add_dap(
self,
@@ -362,6 +404,8 @@ class BECWaveformWidget(BECWidget, QWidget):
Returns:
BECCurve: The curve object.
"""
if self.clear_curves_on_plot_update is True:
self.waveform.clear_source(source="DAP")
return self.waveform.add_dap(
x_name=x_name,
y_name=y_name,
@@ -543,6 +587,23 @@ class BECWaveformWidget(BECWidget, QWidget):
"""
self.waveform.set_auto_range(enabled, axis)
def toggle_roi(self, checked: bool):
"""Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
self.waveform.toggle_roi(checked)
def select_roi(self, region: tuple):
"""
Set the region of interest of the plot widget.
Args:
region(tuple): Region of interest.
"""
self.waveform.select_roi(region)
@SafeSlot()
def _auto_range_from_toolbar(self):
"""
@@ -560,6 +621,15 @@ class BECWaveformWidget(BECWidget, QWidget):
"""
self.waveform.set_grid(x_grid, y_grid)
def set_outer_axes(self, show: bool):
"""
Set the outer axes visibility of the plot widget.
Args:
show(bool): Visibility of the outer axes.
"""
self.waveform.set_outer_axes(show)
def lock_aspect_ratio(self, lock: bool):
"""
Lock the aspect ratio of the plot widget.
@@ -633,11 +703,12 @@ class BECWaveformWidget(BECWidget, QWidget):
def main(): # pragma: no cover
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication(sys.argv)
set_theme("auto")
widget = BECWaveformWidget()
widget.plot(x_name="samx", y_name="bpm4i")
widget.plot(y_name="bpm3i")
widget.plot(y_name="bpm4a")
widget.plot(y_name="bpm5i")
widget.show()
sys.exit(app.exec_())
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@@ -0,0 +1,39 @@
(user.widgets.bec_progressbar)=
# BEC Progressbar
```{tab} Overview
The BEC Progressbar widget is a general purpose progress bar that follows the BEC theme and style. It can be embedded in any application to display the progress of a task or operation.
## Key Features:
- **Modern Design**: The BEC Progressbar widget is designed with a modern and sleek appearance, following the BEC theme.
- **Customizable**: Users can customize the appearance and behavior of the progress bar to suit their application.
- **Responsive**: The progress bar updates in real-time to reflect the progress of the task or operation.
## Screenshot
![BEC Progressbar](./bec_progressbar_running.png)
![BEC Progressbar](./bec_progressbar_completed.png)
```
````{tab} Examples
The `BECProgressBar` widget can be integrated within a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BECDesigner`. Below are examples demonstrating how to create and use the `BECProgressBar` widget.
## Example 1 - Adding BEC Status Box to BECDockArea
In this example, we demonstrate how to add a `BECProgressBar` widget to a `BECDockArea`, allowing users to manually set and update the progress states.
```python
# Add a new dock with a BECStatusBox widget
pb = gui.add_dock().add_widget("BECProgressBar")
pb.set_value(50)
```
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.BECProgressbar.rst
```
````
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -15,7 +15,7 @@ The [`BEC Status Box`](/api_reference/_autosummary/bec_widgets.cli.client.BECSta
````{tab} Examples
The `BECStatusBox` widget can be integrated within a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `QtDesigner`. Below are examples demonstrating how to create and use the `BECStatusBox` widget.
The `BECStatusBox` widget can be integrated within a [`BECDockArea`](user.widgets.bec_dock_area) or used as an individual component in your application through `BECDesigner`. Below are examples demonstrating how to create and use the `BECStatusBox` widget.
## Example 1 - Adding BEC Status Box to BECDockArea
@@ -36,7 +36,7 @@ waveform.dap_summary_update.connect(lmfit_dialog.update_summary_tree)
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.LMFitDialog.rst
.. include:: /api_reference/_autosummary/bec_widgets.widgets.lmfit_dialog.lmfit_dialog.LMFitDialog.rst
```
````
@@ -10,8 +10,28 @@ The [`PositionIndicator`](/api_reference/_autosummary/bec_widgets.cli.client.Pos
- **Position Visualization**: Displays the current position of a motor on a linear scale, showing its location relative to the defined limits.
- **Customizable Range**: The widget allows you to set the minimum and maximum range, adapting to different motor configurations.
- **Real-Time Updates**: Responds to real-time updates, allowing the position indicator to move dynamically as the motor's position changes.
- **Compact Design**: The widget is designed to be compact and visually appealing, making it suitable for various GUI applications.
- **Customizable Appearance**: The appearance of the position indicator can be customized to match the overall design of your application, including colors, orientation, and size.
- **QtDesigner Integration**: Can be added directly in code or through `QtDesigner`, making it adaptable to various use cases.
## BEC Designer Customization
Within the BECDesigner's [property editor](https://doc.qt.io/qt-6/designer-widget-mode.html#the-property-editor/), the `PositionIndicator` widget can be customized to suit your application's requirements. The widget provides the following customization options:
- **minimum**: The minimum value of the position indicator.
- **maximum**: The maximum value of the position indicator.
- **value**: The current value of the position indicator.
- **vertical**: A boolean value indicating whether the position indicator is oriented vertically or horizontally.
- **indicator_width**: The width of the position indicator.
- **rounded_corners**: The radius of the rounded corners of the position indicator.
- **indicator_color**: The color of the position indicator.
- **background_color**: The color of the background of the position indicator.
- **use_color_palette**: A boolean value indicating whether to use the color palette for the position indicator or the custom colors.
**BEC Designer properties:**
```{figure} ./position_indicator_designer_props.png
```
````
````{tab} Examples
@@ -33,7 +53,7 @@ position_indicator = PositionIndicator()
# Create a slider to simulate position changes
slider = QSlider(Qt.Horizontal)
slider.valueChanged.connect(lambda value: position_indicator.on_position_update(value / 100))
slider.valueChanged.connect(lambda value: position_indicator.set_value(value))
# Create a layout and add the widgets
layout = QVBoxLayout()
@@ -63,7 +83,13 @@ The `PositionIndicator` can be added to your GUI layout using `QtDesigner`. Once
```python
# Example: Updating the position in a QtDesigner-based application
self.position_indicator.on_position_update(new_position_value)
self.position_indicator.set_value(new_position_value)
```
````
````{tab} API
```{eval-rst}
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.PositionIndicator.rst
```
````
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -30,6 +30,7 @@ Within the BECDesigner's [property editor](https://doc.qt.io/qt-6/designer-widge
- **Hide Scan Control**: Allows you to hide the scan control buttons from the widget interface. This is useful when you want to place the control buttons in a different location.
- **Hide Scan Selection**: Allows you to hide the scan selection combobox from the widget interface. This is useful when you want to restrict the user to a specific scan type or implement a custom scan selection mechanism.
- **Hide Scan Remember Toggle**: Allows you to hide the toggle button that reloads scan parameters from the last executed scan. This is useful if you want to disable or restrict this functionality in specific scenarios.
- **Hide Bundle Buttons**: Allows you to hide the buttons that add or remove argument bundles from the widget interface. This is useful when you want to restrict the user from adding additional motor bundles to the scan by accident.
**BEC Designer properties:**
```{figure} ./hide_scan_control.png
+5 -20
View File
@@ -8,8 +8,11 @@ The [`Text Box Widget`](/api_reference/_autosummary/bec_widgets.cli.client.TextB
## Key Features:
- **Text Display**: Display either plain text or HTML content, with automatic detection of the format.
- **Customizable Appearance**: Set the background and font colors to match the design of your application.
- **Font Size Adjustment**: Customize the font size of the displayed text for better readability.
- **Automatic styling**: The widget automatically adheres to BEC's style guides. No need to worry about background colors, font sizes, or other appearance settings.
## BEC Designer Properties
```{figure} ../../assets/widget_screenshots/text_box_properties.png
```
````
@@ -38,24 +41,6 @@ The `TextBox` widget can automatically detect and render HTML content. This exam
text_box.set_text("<h1>Welcome to BEC Widgets</h1><p>This is an example of displaying <strong>HTML</strong> text.</p>")
```
## Example 3 - Customizing Appearance
The `TextBox` widget allows you to customize the background and font colors to fit your application's design. Below is an example of how to set these properties.
```python
# Set the background color to white and the font color to black
text_box.set_color(background_color="#FFF", font_color="#000")
```
## Example 4 - Adjusting Font Size
To improve readability or fit more text within the widget, you can adjust the font size.
```python
# Set the font size to 14 pixels
text_box.set_font_size(14)
```
````
````{tab} API
@@ -75,7 +75,7 @@ dev.bpm3i.sim.select_sim_model("StepModel")
```
## Example 4 - Adding Data Processing Pipeline Curve with LMFit Models
In addition to the scan curve, you can also add a second curve that fits the signal using a specified model from [LMFit](https://lmfit.github.io/lmfit-py/builtin_models.html). The following code snippet demonstrates how to create a 1D waveform curve with an attached DAP process, or how to add a DAP process to an existing curve using the BEC CLI. Please note that for this example, both devices were set as Gaussian signals.
In addition to the scan curve, you can also add a second curve that fits the signal using a specified model from [LMFit](https://lmfit.github.io/lmfit-py/builtin_models.html). The following code snippet demonstrates how to create a 1D waveform curve with an attached DAP process, or how to add a DAP process to an existing curve using the BEC CLI. Please note that for this example, both devices were set as Gaussian signals. You can also add a region of interest (roi) to the plot which will respected by all running DAP processes.
```python
# Add a new dock, a new BECFigure, and a BECWaveForm to the dock with a GaussianModel DAP
@@ -87,6 +87,13 @@ plt.plot(x_name='samx', y_name='bpm3a')
# Add DAP to the second curve
plt.add_dap(x_name='samx', y_name='bpm3a', dap="GaussianModel")
# Add roi to the plot, this limits the DAP fit to the selected region x_min=-1, x_max=1
# The fit will automatically update
plt.select_roi(region=(-1, 1))
# To remove the DAP from the curve, you can use the toggle button in the toolbar or the following command
plt.toggle_roi(False)
```
To get the parameters of the fit, you need to retrieve the curve objects and call the `dap_params` property.
+9
View File
@@ -191,6 +191,14 @@ Angular like toggle switch.
Display spinner widget for loading or device movement.
```
```{grid-item-card} BEC Progressbar
:link: user.widgets.bec_progressbar
:link-type: ref
:img-top: /assets/widget_screenshots/bec_progressbar.png
Modern progress bar for BEC.
```
```{grid-item-card} Position Indicator
:link: user.widgets.position_indicator
:link-type: ref
@@ -239,6 +247,7 @@ text_box/text_box.md
website/website.md
toggle/toggle.md
spinner/spinner.md
bec_progressbar/bec_progressbar.md
device_input/device_input.md
position_indicator/position_indicator.md
lmfit_dialog/lmfit_dialog.md
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.105.0"
version = "0.112.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
@@ -183,11 +183,20 @@ def test_dap_rpc(rpc_server_figure, bec_client_lib, qtbot):
def wait_for_fit():
dap_curve = plt.get_curve("bpm4i-bpm4i-GaussianModel")
fit_params = dap_curve.dap_params
if fit_params is None:
return False
print(fit_params)
return np.isclose(fit_params["center"], 5, atol=0.5)
qtbot.waitUntil(wait_for_fit, timeout=10000)
# Repeat fit after adding a region of interest
plt.select_roi(region=(3, 7))
res = scans.line_scan(dev.samx, 0, 8, steps=50, relative=False)
res.wait()
qtbot.waitUntil(wait_for_fit, timeout=10000)
def test_removing_subplots(rpc_server_figure, bec_client_lib):
fig = BECFigure(rpc_server_figure)
+1
View File
@@ -177,6 +177,7 @@ def test_motor_map_init_from_config(qtbot, mocked_client):
"y_lim": None,
"x_grid": True,
"y_grid": True,
"outer_axes": False,
},
"signals": {
"source": "device_readback",
+35
View File
@@ -0,0 +1,35 @@
import numpy as np
import pytest
from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar
@pytest.fixture
def progressbar(qtbot):
widget = BECProgressBar()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_progressbar(progressbar):
progressbar.update()
def test_progressbar_set_value(qtbot, progressbar):
progressbar.set_minimum(0)
progressbar.set_maximum(100)
progressbar.set_value(50)
progressbar.paintEvent(None)
qtbot.waitUntil(
lambda: np.isclose(
progressbar._value, progressbar._user_value * progressbar._oversampling_factor
)
)
def test_progressbar_label(progressbar):
progressbar.label_template = "Test: $value"
progressbar.set_value(50)
assert progressbar.center_label.text() == "Test: 50"
@@ -65,6 +65,8 @@ def test_client_generator_with_black_formatting():
'''\
# This file was automatically generated by generate_cli.py
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
+37
View File
@@ -0,0 +1,37 @@
import pytest
from qtpy.QtCore import Qt
from bec_widgets.qt_utils.palette_viewer import PaletteViewer
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
@pytest.fixture
def palette_viewer(qtbot):
widget = PaletteViewer()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_palette_viewer_renders_palette_and_accents(qtbot, palette_viewer):
assert palette_viewer.frame_layout.count() == 28
palette_viewer.clear_palette()
assert palette_viewer.frame_layout.count() == 0
palette_viewer.update_palette()
assert palette_viewer.frame_layout.count() == 28
def test_palette_viewer_updates_on_theme_change(qtbot, palette_viewer):
light_window_text_color = palette_viewer.frame_layout.itemAt(1).itemAt(0).widget().text()
assert "(windowText)" in light_window_text_color
light_hex_color = light_window_text_color.split(" ")[0]
button = palette_viewer.findChild(DarkModeButton)
qtbot.mouseClick(button.mode_button, Qt.MouseButton.LeftButton)
qtbot.wait(100)
dark_window_text_color = palette_viewer.frame_layout.itemAt(1).itemAt(0).widget().text()
assert "(windowText)" in dark_window_text_color
dark_hex_color = dark_window_text_color.split(" ")[0]
assert light_hex_color != dark_hex_color
@@ -0,0 +1,42 @@
import pytest
from bec_widgets.widgets.position_indicator.position_indicator import PositionIndicator
@pytest.fixture
def position_indicator(qtbot):
"""Fixture for PositionIndicator widget"""
pi = PositionIndicator()
qtbot.addWidget(pi)
qtbot.waitExposed(pi)
return pi
def test_position_indicator_set_range(position_indicator):
"""
Test set_range method of PositionIndicator
"""
position_indicator.set_range(0, 20)
assert position_indicator.minimum == 0
assert position_indicator.maximum == 20
def test_position_indicator_set_value(position_indicator):
"""
Test set_value method of PositionIndicator and the correct mapping of the value
within the paintEvent method
"""
# pylint: disable=protected-access
position_indicator.set_value(50)
assert position_indicator.position == 50
position_indicator.paintEvent(None)
assert position_indicator._current_indicator_position == 50
position_indicator.set_value(100)
position_indicator.paintEvent(None)
assert position_indicator._draw_position == position_indicator.width()
position_indicator.vertical = True
position_indicator.paintEvent(None)
assert position_indicator._draw_position == position_indicator.height()
+10 -34
View File
@@ -1,9 +1,6 @@
import re
from unittest import mock
import pytest
from bec_widgets.widgets.text_box.text_box import TextBox
from bec_widgets.widgets.text_box.text_box import DEFAULT_TEXT, TextBox
from .client_mocks import mocked_client
@@ -18,37 +15,16 @@ def text_box_widget(qtbot, mocked_client):
def test_textbox_widget(text_box_widget):
"""Test the TextBox widget."""
# Test default
assert text_box_widget.config.text == DEFAULT_TEXT
# Test set text
text = "Hello World!"
text_box_widget.set_text(text)
assert text_box_widget.toPlainText() == text
text_box_widget.set_color("#FFDDC1", "#123456")
text_box_widget.set_font_size(20)
assert (
text_box_widget.styleSheet() == "background-color: #FFDDC1; color: #123456; font-size: 20px"
)
text_box_widget.set_color("white", "blue")
text_box_widget.set_font_size(14)
assert text_box_widget.styleSheet() == "background-color: white; color: blue; font-size: 14px"
text_box_widget.set_plain_text(text)
assert text_box_widget.plain_text == text
# Test set HTML text
text = "<h1>Welcome to PyQt6</h1><p>This is an example of displaying <strong>HTML</strong> text.</p>"
with mock.patch.object(text_box_widget, "setHtml") as mocked_set_html:
text_box_widget.set_text(text)
assert mocked_set_html.call_count == 1
assert mocked_set_html.call_args == mock.call(text)
def test_textbox_change_theme(text_box_widget):
"""Test change theme functionaility"""
# Default is dark theme
text_box_widget.change_theme()
assert text_box_widget.config.theme == "light"
text_box_widget.set_html_text(text)
assert (
text_box_widget.styleSheet()
== f"background-color: #FFF; color: #000; font-size: {text_box_widget.config.font_size}px"
)
text_box_widget.change_theme()
assert text_box_widget.config.theme == "dark"
assert (
text_box_widget.styleSheet()
== f"background-color: #000; color: #FFF; font-size: {text_box_widget.config.font_size}px"
text_box_widget.plain_text
== "Welcome to PyQt6\nThis is an example of displaying HTML text."
)
+1
View File
@@ -72,6 +72,7 @@ def test_create_waveform1D_by_config(qtbot, mocked_client):
"y_lim": None,
"x_grid": False,
"y_grid": False,
"outer_axes": False,
},
"color_palette": "magma",
"curves": {
+66 -1
View File
@@ -7,6 +7,7 @@ from qtpy.QtWidgets import QApplication
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.utils.colors import apply_theme, get_theme_palette, set_theme
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings
from bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog import CurveSettings
from bec_widgets.widgets.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog import (
@@ -20,6 +21,8 @@ from .conftest import create_widget
@pytest.fixture
def waveform_widget(qtbot, mocked_client):
models = ["GaussianModel", "LorentzModel", "SineModel"]
mocked_client.dap._available_dap_plugins.keys.return_value = models
widget = BECWaveformWidget(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
@@ -357,7 +360,8 @@ def test_curve_dialog_async(qtbot, waveform_widget):
def test_curve_dialog_dap(qtbot, waveform_widget):
waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# Don't use default dap for curve_dialog dialog
waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="LorentzModel")
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
@@ -369,6 +373,7 @@ def test_curve_dialog_dap(qtbot, waveform_widget):
assert curve_dialog.widget.ui.dap_table.rowCount() == 1
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 0).text() == "bpm4i"
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 1).text() == "bpm4i"
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 2).currentText() == "LorentzModel"
assert curve_dialog.widget.ui.x_mode.currentText() == "device"
assert curve_dialog.widget.ui.x_name.isEnabled() == True
assert curve_dialog.widget.ui.x_entry.isEnabled() == True
@@ -514,3 +519,63 @@ def test_waveform_widget_theme_update(qtbot, waveform_widget):
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert waveform_color == waveform_color_dark
def test_waveform_roi_selection_creation(waveform_widget, qtbot):
"""Test ROI selection for waveform widget.
This checks that the ROI select is properly created and removed when the button is toggled.
"""
# Check if curve is create upon ROI select slot
# This also checks that the button in the toolbar works
container = []
def callback(msg):
container.append(msg)
waveform_widget.waveform.roi_active.connect(callback)
assert waveform_widget.waveform.roi_select is None
assert waveform_widget.waveform.roi_region == (None, None)
# Toggle the ROI select
waveform_widget.toogle_roi_select(True)
assert isinstance(waveform_widget.waveform.roi_select, LinearRegionWrapper)
# This is the default region for the pg.LinearRegionItem
assert waveform_widget.waveform.roi_region == (0, 1)
# Untoggle the ROI select
waveform_widget.toogle_roi_select(False)
assert waveform_widget.waveform.roi_select is None
assert container[0] is True
assert container[1] is False
def test_waveform_roi_selection_updates_fit(waveform_widget, qtbot):
"""This test checks that upon selection of a new region, the fit is updated and all signals are emitted as expected."""
container = []
def callback(msg):
container.append(msg)
waveform_widget.waveform.roi_changed.connect(callback)
# Mock refresh_dap method
with patch.object(waveform_widget.waveform, "refresh_dap") as mock_refresh_dap:
waveform_widget.toogle_roi_select(True)
waveform_widget.waveform.roi_select.linear_region_selector.setRegion([0.5, 1.5])
qtbot.wait(200)
assert waveform_widget.waveform.roi_region == (0.5, 1.5)
waveform_widget.toogle_roi_select(False)
assert waveform_widget.waveform.roi_region == (None, None)
assert len(container) == 1
assert container[0] == (0.5, 1.5)
# 3 refresh DAP calls: 1x upon hook, 1x unhook and 1x from roi_changed
assert mock_refresh_dap.call_count == 3
def test_waveform_roi_selection_change_color(waveform_widget, qtbot):
"""This test checks that the color of the ROI region can be changed."""
waveform_widget.toogle_roi_select(True)
waveform_widget.waveform.roi_select.change_roi_color((QColor("red"), QColor("blue")))
# I can only get the brush from the RegionSelectItem
assert (
waveform_widget.waveform.roi_select.linear_region_selector.currentBrush.color()
== QColor("red")
)