mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-11 19:20:53 +02:00
Compare commits
27 Commits
fix/logpan
...
v2.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f68f072da3 | ||
| 1df6c1925b | |||
| 6b939ac34d | |||
|
|
6bcf20af07 | ||
| a64cf0dd87 | |||
| cd4e90a79f | |||
|
|
49a96a18d6 | ||
| 2b4454a291 | |||
| d12bd9fe1a | |||
| d0c1ac0cf5 | |||
| f90150d1c7 | |||
|
|
c684b6c230 | ||
| 91126168b6 | |||
| 7322cd194f | |||
| d9dc60ee99 | |||
|
|
e4cd4891ad | ||
| 12f8c82eb5 | |||
|
|
f46ffb14e1 | ||
| 2b9919bb34 | |||
| 822e7d06ff | |||
| 91195ae0fd | |||
| a6c5c21afa | |||
|
|
ff06954cb7 | ||
| c8128faf79 | |||
|
|
6b65a94c81 | ||
| bf172b8431 | |||
| 05329ab50f |
16
.github/workflows/end2end-conda.yml
vendored
16
.github/workflows/end2end-conda.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
||||
CHILD_PIPELINE_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
BEC_CORE_BRANCH: main # Set the branch you want for bec
|
||||
OPHYD_DEVICES_BRANCH: main # Set the branch you want for ophyd_devices
|
||||
PLUGIN_REPO_BRANCH: main # Set the branch you want for the plugin repo
|
||||
PROJECT_PATH: ${{ github.repository }}
|
||||
QTWEBENGINE_DISABLE_SANDBOX: 1
|
||||
QT_QPA_PLATFORM: "offscreen"
|
||||
@@ -39,10 +40,19 @@ jobs:
|
||||
echo -e "\033[35;1m Using branch $OPHYD_DEVICES_BRANCH of OPHYD_DEVICES \033[0;m";
|
||||
git clone --branch $OPHYD_DEVICES_BRANCH https://github.com/bec-project/ophyd_devices.git
|
||||
export OHPYD_DEVICES_PATH=$PWD/ophyd_devices
|
||||
echo -e "\033[35;1m Using branch $PLUGIN_REPO_BRANCH of bec_testing_plugin \033[0;m";
|
||||
git clone --branch $PLUGIN_REPO_BRANCH https://github.com/bec-project/bec_testing_plugin.git
|
||||
cd ./bec
|
||||
conda create -q -n test-environment python=3.11
|
||||
source ./bin/install_bec_dev.sh -t
|
||||
cd ../
|
||||
pip install -e ./ophyd_devices
|
||||
pip install -e .[dev,pyside6]
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
pip install -e ./ophyd_devices -e .[dev,pyside6] -e ./bec_testing_plugin
|
||||
pytest -v --files-path ./ --start-servers --random-order ./tests/end-2-end
|
||||
|
||||
- name: Upload logs if job fails
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pytest-logs
|
||||
path: ./logs/*.log
|
||||
retention-days: 7
|
||||
9
.github/workflows/formatter.yml
vendored
9
.github/workflows/formatter.yml
vendored
@@ -14,10 +14,15 @@ jobs:
|
||||
|
||||
- name: Run black and isort
|
||||
run: |
|
||||
pip install black isort
|
||||
pip install -e .[dev]
|
||||
pip install uv
|
||||
uv pip install --system black isort
|
||||
uv pip install --system -e .[dev]
|
||||
black --check --diff --color .
|
||||
isort --check --diff ./
|
||||
|
||||
- name: Check for disallowed imports from PySide
|
||||
run: '! grep -re "from PySide6\." bec_widgets/ | grep -v -e "PySide6.QtDesigner" -e "PySide6.scripts"'
|
||||
|
||||
Pylint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
124
CHANGELOG.md
124
CHANGELOG.md
@@ -1,6 +1,130 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v2.10.2 (2025-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove unnecessary PySide imports
|
||||
([`1df6c19`](https://github.com/bec-project/bec_widgets/commit/1df6c1925b6ec88df8d7a1a5a79a5ddc6b1161b5))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Check for disallowed imports from PySide
|
||||
([`6b939ac`](https://github.com/bec-project/bec_widgets/commit/6b939ac34d01cdbb0e8e32a0bd4e56cad032e75b))
|
||||
|
||||
|
||||
## v2.10.1 (2025-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **console**: Qt console widget deleted
|
||||
([`cd4e90a`](https://github.com/bec-project/bec_widgets/commit/cd4e90a79fcdbc96f4ec23db22375d05a48731db))
|
||||
|
||||
### Build System
|
||||
|
||||
- Pyte removed from dependencies
|
||||
([`a64cf0d`](https://github.com/bec-project/bec_widgets/commit/a64cf0dd871c1419e02d3803c74cc45966baac19))
|
||||
|
||||
|
||||
## v2.10.0 (2025-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **waveform**: Waveform only update async data when scan is currently running
|
||||
([`f90150d`](https://github.com/bec-project/bec_widgets/commit/f90150d1c708331d4ee78f82ebf5ef23cd81fd17))
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
- Add job logs to e2e test
|
||||
([`d12bd9f`](https://github.com/bec-project/bec_widgets/commit/d12bd9fe1a010babc94dc86405d1b75a2b07534c))
|
||||
|
||||
- Fix artifact version
|
||||
([`2b4454a`](https://github.com/bec-project/bec_widgets/commit/2b4454a291bc69399ddd08780c44e1339825fb36))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: Large async dataset warning popup
|
||||
([`d0c1ac0`](https://github.com/bec-project/bec_widgets/commit/d0c1ac0cf5d421d14c9e050ccf5832cd30ca0764))
|
||||
|
||||
|
||||
## v2.9.2 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Logpanel error cycle
|
||||
([`d9dc60e`](https://github.com/bec-project/bec_widgets/commit/d9dc60ee9974e2e6e6005378cc17ef088a4ded2c))
|
||||
|
||||
- Move log panel to bec connector and add rate limiter
|
||||
([`7322cd1`](https://github.com/bec-project/bec_widgets/commit/7322cd194fcf7f56d41c86ecbcd97a5d8bd60c3e))
|
||||
|
||||
- **log_panel**: Removed lambda callback method
|
||||
([`9112616`](https://github.com/bec-project/bec_widgets/commit/91126168b62f3e1623521ceb205dd854287cfef7))
|
||||
|
||||
|
||||
## v2.9.1 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make registry update log message debug level
|
||||
([`12f8c82`](https://github.com/bec-project/bec_widgets/commit/12f8c82eb59ed6a7273b57126efe340bf37b65cc))
|
||||
|
||||
|
||||
## v2.9.0 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **DeviceSignalInput**: Improve robustness
|
||||
([`91195ae`](https://github.com/bec-project/bec_widgets/commit/91195ae0fdf024daf2daaa4ea2963992b4e40e04))
|
||||
|
||||
use set for storing filter properties to allow multiple set to true or false
|
||||
|
||||
### Code Style
|
||||
|
||||
- Typing in bec_dispatcher
|
||||
([`a6c5c21`](https://github.com/bec-project/bec_widgets/commit/a6c5c21afaa6dcf33ce71027e8730354ee34e3b4))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Add usage docs for signal label widget
|
||||
([`2b9919b`](https://github.com/bec-project/bec_widgets/commit/2b9919bb34a66708f4b910ffc17dc253e9b7f70d))
|
||||
|
||||
### Features
|
||||
|
||||
- (#569) add signal label widget
|
||||
([`822e7d0`](https://github.com/bec-project/bec_widgets/commit/822e7d06ff7479d006ae99942fed5e2c836831ce))
|
||||
|
||||
add a widget which shows the current value of a signal from BEC. configurable with many properties
|
||||
in designer. intended for use mainly in static GUIs.
|
||||
|
||||
|
||||
## v2.8.4 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **crosshair**: Label decimal precision is dynamically scaled with the plot zoom; API of all
|
||||
affected widgets adjusted; option added to PlotBase; closes #637
|
||||
([`c8128fa`](https://github.com/bec-project/bec_widgets/commit/c8128faf79c43487921aada9dbf1869ef5bda93c))
|
||||
|
||||
|
||||
## v2.8.3 (2025-05-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Guard plugin repo import in e2e test
|
||||
([`bf172b8`](https://github.com/bec-project/bec_widgets/commit/bf172b8431ec207f39206d2a0446908f7186858a))
|
||||
|
||||
### Refactoring
|
||||
|
||||
- Store modules with widget search
|
||||
([`b225a7c`](https://github.com/bec-project/bec_widgets/commit/b225a7cc90b55697211c28d9411b6f85c8077217))
|
||||
|
||||
### Testing
|
||||
|
||||
- **e2e**: Add tests involving plugin repo
|
||||
([`05329ab`](https://github.com/bec-project/bec_widgets/commit/05329ab50fe10ffc3c19ef3eb408912bb9068de3))
|
||||
|
||||
|
||||
## v2.8.2 (2025-05-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -52,6 +52,7 @@ _Widgets = {
|
||||
"ScanControl": "ScanControl",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalComboBox": "SignalComboBox",
|
||||
"SignalLabel": "SignalLabel",
|
||||
"SignalLineEdit": "SignalLineEdit",
|
||||
"StopButton": "StopButton",
|
||||
"TextBox": "TextBox",
|
||||
@@ -1221,6 +1222,20 @@ class Image(RPCBase):
|
||||
Set auto range for the y-axis.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def color_map(self) -> "str":
|
||||
@@ -2350,6 +2365,20 @@ class MultiWaveform(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def highlighted_index(self):
|
||||
@@ -3315,6 +3344,20 @@ class ScatterWaveform(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def main_curve(self) -> "ScatterCurve":
|
||||
@@ -3417,6 +3460,78 @@ class SignalComboBox(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class SignalLabel(RPCBase):
|
||||
@property
|
||||
@rpc_call
|
||||
def custom_label(self) -> "str":
|
||||
"""
|
||||
Use a cusom label rather than the signal name
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def custom_units(self) -> "str":
|
||||
"""
|
||||
Use a custom unit string
|
||||
"""
|
||||
|
||||
@custom_label.setter
|
||||
@rpc_call
|
||||
def custom_label(self) -> "str":
|
||||
"""
|
||||
Use a cusom label rather than the signal name
|
||||
"""
|
||||
|
||||
@custom_units.setter
|
||||
@rpc_call
|
||||
def custom_units(self) -> "str":
|
||||
"""
|
||||
Use a custom unit string
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def decimal_places(self) -> "int":
|
||||
"""
|
||||
Format to a given number of decimal_places. Set to 0 to disable.
|
||||
"""
|
||||
|
||||
@decimal_places.setter
|
||||
@rpc_call
|
||||
def decimal_places(self) -> "int":
|
||||
"""
|
||||
Format to a given number of decimal_places. Set to 0 to disable.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_default_units(self) -> "bool":
|
||||
"""
|
||||
Show default units obtained from the signal alongside it
|
||||
"""
|
||||
|
||||
@show_default_units.setter
|
||||
@rpc_call
|
||||
def show_default_units(self) -> "bool":
|
||||
"""
|
||||
Show default units obtained from the signal alongside it
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def show_select_button(self) -> "bool":
|
||||
"""
|
||||
Show the button to select the signal to display
|
||||
"""
|
||||
|
||||
@show_select_button.setter
|
||||
@rpc_call
|
||||
def show_select_button(self) -> "bool":
|
||||
"""
|
||||
Show the button to select the signal to display
|
||||
"""
|
||||
|
||||
|
||||
class SignalLineEdit(RPCBase):
|
||||
"""Line edit widget for device input with autocomplete for device names."""
|
||||
|
||||
@@ -3789,6 +3904,20 @@ class Waveform(RPCBase):
|
||||
The font size of the legend font.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
@rpc_call
|
||||
def minimal_crosshair_precision(self) -> "int":
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def curves(self) -> "list[Curve]":
|
||||
@@ -3841,6 +3970,48 @@ class Waveform(RPCBase):
|
||||
The color palette of the figure widget.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def skip_large_dataset_warning(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@skip_large_dataset_warning.setter
|
||||
@rpc_call
|
||||
def skip_large_dataset_warning(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def skip_large_dataset_check(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@skip_large_dataset_check.setter
|
||||
@rpc_call
|
||||
def skip_large_dataset_check(self) -> "bool":
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def max_dataset_size_mb(self) -> "float":
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
|
||||
@max_dataset_size_mb.setter
|
||||
@rpc_call
|
||||
def max_dataset_size_mb(self) -> "float":
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def plot(
|
||||
self,
|
||||
|
||||
@@ -163,7 +163,7 @@ class BECDispatcher:
|
||||
def connect_slot(
|
||||
self,
|
||||
slot: Callable,
|
||||
topics: Union[EndpointInfo, str, list[Union[EndpointInfo, str]]],
|
||||
topics: EndpointInfo | str | list[EndpointInfo] | list[str],
|
||||
cb_info: dict | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
@@ -172,7 +172,7 @@ class BECDispatcher:
|
||||
Args:
|
||||
slot (Callable): A slot method/function that accepts two inputs: content and metadata of
|
||||
the corresponding pub/sub message
|
||||
topics (EndpointInfo | str | list): A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics that can typically be acquired via bec_lib.MessageEndpoints
|
||||
cb_info (dict | None): A dictionary containing information about the callback. Defaults to None.
|
||||
"""
|
||||
qt_slot = QtThreadSafeCallback(cb=slot, cb_info=cb_info)
|
||||
@@ -183,13 +183,15 @@ class BECDispatcher:
|
||||
topics_str, _ = self.client.connector._convert_endpointinfo(topics)
|
||||
qt_slot.topics.update(set(topics_str))
|
||||
|
||||
def disconnect_slot(self, slot: Callable, topics: Union[str, list]):
|
||||
def disconnect_slot(
|
||||
self, slot: Callable, topics: EndpointInfo | str | list[EndpointInfo] | list[str]
|
||||
):
|
||||
"""
|
||||
Disconnect a slot from a topic.
|
||||
|
||||
Args:
|
||||
slot(Callable): The slot to disconnect
|
||||
topics(Union[str, list]): The topic(s) to disconnect from
|
||||
topics EndpointInfo | str | list[EndpointInfo] | list[str]: A topic or list of topics to unsub from.
|
||||
"""
|
||||
# find the right slot to disconnect from ;
|
||||
# slot callbacks are wrapped in QtThreadSafeCallback objects,
|
||||
|
||||
@@ -34,13 +34,21 @@ class Crosshair(QObject):
|
||||
coordinatesChanged2D = Signal(tuple)
|
||||
coordinatesClicked2D = Signal(tuple)
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
plot_item: pg.PlotItem,
|
||||
precision: int | None = None,
|
||||
*,
|
||||
min_precision: int = 2,
|
||||
parent=None,
|
||||
):
|
||||
"""
|
||||
Crosshair for 1D and 2D plots.
|
||||
|
||||
Args:
|
||||
plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached.
|
||||
precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None.
|
||||
precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range.
|
||||
min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2.
|
||||
parent (QObject, optional): Parent object for the QObject. Defaults to None.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
@@ -48,7 +56,9 @@ class Crosshair(QObject):
|
||||
self.is_log_x = None
|
||||
self.is_derivative = None
|
||||
self.plot_item = plot_item
|
||||
self.precision = precision
|
||||
self._precision = precision
|
||||
self._min_precision = max(0, int(min_precision)) # ensure non‑negative
|
||||
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False)
|
||||
self.v_line.skip_auto_range = True
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False)
|
||||
@@ -93,6 +103,56 @@ class Crosshair(QObject):
|
||||
|
||||
self._connect_to_theme_change()
|
||||
|
||||
@property
|
||||
def precision(self) -> int | None:
|
||||
"""Fixed number of decimals; ``None`` enables dynamic mode."""
|
||||
return self._precision
|
||||
|
||||
@precision.setter
|
||||
def precision(self, value: int | None):
|
||||
"""
|
||||
Set the fixed number of decimals to display.
|
||||
|
||||
Args:
|
||||
value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range.
|
||||
"""
|
||||
self._precision = value
|
||||
|
||||
@property
|
||||
def min_precision(self) -> int:
|
||||
"""Lower bound on decimals when dynamic precision is used."""
|
||||
return self._min_precision
|
||||
|
||||
@min_precision.setter
|
||||
def min_precision(self, value: int):
|
||||
"""
|
||||
Set the lower bound on decimals when dynamic precision is used.
|
||||
|
||||
Args:
|
||||
value(int): The minimum number of decimals to display. Must be non-negative.
|
||||
"""
|
||||
self._min_precision = max(0, int(value))
|
||||
|
||||
def _current_precision(self) -> int:
|
||||
"""
|
||||
Get the current precision based on the view range or fixed precision.
|
||||
"""
|
||||
if self._precision is not None:
|
||||
return self._precision
|
||||
|
||||
# Dynamically choose precision from the smaller visible span
|
||||
view_range = self.plot_item.vb.viewRange()
|
||||
x_span = abs(view_range[0][1] - view_range[0][0])
|
||||
y_span = abs(view_range[1][1] - view_range[1][0])
|
||||
|
||||
# Ignore zero spans that can appear during initialisation
|
||||
spans = [s for s in (x_span, y_span) if s > 0]
|
||||
span = min(spans) if spans else 1.0
|
||||
|
||||
exponent = np.floor(np.log10(span)) # order of magnitude
|
||||
decimals = max(0, int(-exponent) + 1)
|
||||
return max(self._min_precision, decimals)
|
||||
|
||||
def _connect_to_theme_change(self):
|
||||
"""Connect to the theme change signal."""
|
||||
qapp = QApplication.instance()
|
||||
@@ -324,6 +384,7 @@ class Crosshair(QObject):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
@@ -334,8 +395,8 @@ class Crosshair(QObject):
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesChanged1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
@@ -380,6 +441,7 @@ class Crosshair(QObject):
|
||||
# not sure how we got here, but just to be safe...
|
||||
return
|
||||
|
||||
precision = self._current_precision()
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.PlotDataItem):
|
||||
name = item.name() or str(id(item))
|
||||
@@ -391,8 +453,8 @@ class Crosshair(QObject):
|
||||
x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y)
|
||||
coordinate_to_emit = (
|
||||
name,
|
||||
round(x_snapped_scaled, self.precision),
|
||||
round(y_snapped_scaled, self.precision),
|
||||
round(x_snapped_scaled, precision),
|
||||
round(y_snapped_scaled, precision),
|
||||
)
|
||||
self.coordinatesClicked1D.emit(coordinate_to_emit)
|
||||
elif isinstance(item, pg.ImageItem):
|
||||
@@ -443,7 +505,8 @@ class Crosshair(QObject):
|
||||
"""
|
||||
x, y = pos
|
||||
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
|
||||
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
|
||||
precision = self._current_precision()
|
||||
text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})"
|
||||
for item in self.items:
|
||||
if isinstance(item, pg.ImageItem):
|
||||
image = item.image
|
||||
@@ -452,7 +515,7 @@ class Crosshair(QObject):
|
||||
ix = int(np.clip(x, 0, image.shape[0] - 1))
|
||||
iy = int(np.clip(y, 0, image.shape[1] - 1))
|
||||
intensity = image[ix, iy]
|
||||
text += f"\nIntensity: {intensity:.{self.precision}g}"
|
||||
text += f"\nIntensity: {intensity:.{precision}f}"
|
||||
break
|
||||
# Update coordinate label
|
||||
self.coord_label.setText(text)
|
||||
|
||||
@@ -195,7 +195,7 @@ class RPCServer:
|
||||
return
|
||||
self._broadcasted_data = data
|
||||
|
||||
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
logger.debug(f"Broadcasting registry update: {data} for {self.gui_id}")
|
||||
self.client.connector.xadd(
|
||||
MessageEndpoints.gui_registry_state(self.gui_id),
|
||||
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from bec_lib.logger import bec_logger
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from qtpy.QtGui import QCloseEvent
|
||||
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
|
||||
@@ -9,7 +9,7 @@ from bec_widgets.utils.plugin_utils import get_custom_classes
|
||||
logger = bec_logger.logger
|
||||
|
||||
if PYSIDE6:
|
||||
from PySide6.QtUiTools import QUiLoader
|
||||
from qtpy.QtUiTools import QUiLoader
|
||||
|
||||
class CustomUiLoader(QUiLoader):
|
||||
def __init__(self, baseinstance, custom_widgets: dict | None = None):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from bec_lib.callback_handler import EventType
|
||||
from bec_lib.device import Signal
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Property, Slot
|
||||
from qtpy.QtCore import Property
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
@@ -49,7 +50,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
|
||||
self._device = None
|
||||
self.get_bec_shortcuts()
|
||||
self._signal_filter = []
|
||||
self._signal_filter = set()
|
||||
self._signals = []
|
||||
self._hinted_signals = []
|
||||
self._normal_signals = []
|
||||
@@ -60,7 +61,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
|
||||
### Qt Slots ###
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_signal(self, signal: str):
|
||||
"""
|
||||
Set the signal.
|
||||
@@ -76,7 +77,7 @@ class DeviceSignalInputBase(BECWidget):
|
||||
f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}."
|
||||
)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_device(self, device: str | None):
|
||||
"""
|
||||
Set the device. If device is not valid, device will be set to None which happens
|
||||
@@ -90,8 +91,8 @@ class DeviceSignalInputBase(BECWidget):
|
||||
self._device = device
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Slot(dict, dict)
|
||||
@Slot()
|
||||
@SafeSlot(dict, dict)
|
||||
@SafeSlot()
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
@@ -158,9 +159,9 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@include_hinted_signals.setter
|
||||
def include_hinted_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.append(Kind.hinted)
|
||||
self._signal_filter.add(Kind.hinted)
|
||||
else:
|
||||
self._signal_filter.remove(Kind.hinted)
|
||||
self._signal_filter.discard(Kind.hinted)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@@ -171,9 +172,9 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@include_normal_signals.setter
|
||||
def include_normal_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.append(Kind.normal)
|
||||
self._signal_filter.add(Kind.normal)
|
||||
else:
|
||||
self._signal_filter.remove(Kind.normal)
|
||||
self._signal_filter.discard(Kind.normal)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
@Property(bool)
|
||||
@@ -184,9 +185,9 @@ class DeviceSignalInputBase(BECWidget):
|
||||
@include_config_signals.setter
|
||||
def include_config_signals(self, value: bool):
|
||||
if value:
|
||||
self._signal_filter.append(Kind.config)
|
||||
self._signal_filter.add(Kind.config)
|
||||
else:
|
||||
self._signal_filter.remove(Kind.config)
|
||||
self._signal_filter.discard(Kind.config)
|
||||
self.update_signals_from_filters()
|
||||
|
||||
### Properties and Methods ###
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from bec_lib.device import Positioner
|
||||
from qtpy.QtCore import QSize, Signal, Slot
|
||||
from qtpy.QtCore import QSize, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBase,
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,7 +37,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self,
|
||||
parent=None,
|
||||
client=None,
|
||||
config: DeviceSignalInputBase = None,
|
||||
config: DeviceSignalInputBaseConfig | None = None,
|
||||
gui_id: str | None = None,
|
||||
device: str | None = None,
|
||||
signal_filter: str | list[str] | None = None,
|
||||
@@ -65,9 +67,13 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
if default is not None:
|
||||
self.set_signal(default)
|
||||
|
||||
def update_signals_from_filters(self):
|
||||
@SafeSlot()
|
||||
@SafeSlot(dict, dict)
|
||||
def update_signals_from_filters(
|
||||
self, content: dict | None = None, metadata: dict | None = None
|
||||
):
|
||||
"""Update the filters for the combobox"""
|
||||
super().update_signals_from_filters()
|
||||
super().update_signals_from_filters(content, metadata)
|
||||
# pylint: disable=protected-access
|
||||
if FilterIO._find_handler(self) is ComboBoxFilterHandler:
|
||||
if len(self._config_signals) > 0:
|
||||
@@ -84,7 +90,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
|
||||
self.insertItem(0, "Hinted Signals")
|
||||
self.model().item(0).setEnabled(False)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def on_text_changed(self, text: str):
|
||||
"""Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal.
|
||||
For a positioner, the readback value has to be renamed to the device name.
|
||||
|
||||
@@ -1,870 +0,0 @@
|
||||
"""
|
||||
BECConsole is a Qt widget that runs a Bash shell.
|
||||
|
||||
BECConsole VT100 emulation is powered by Pyte,
|
||||
(https://github.com/selectel/pyte).
|
||||
"""
|
||||
|
||||
import collections
|
||||
import fcntl
|
||||
import html
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pyte
|
||||
from pygments.token import Token
|
||||
from pyte.screens import History
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
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, QColor, QPalette, QTextCursor
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
|
||||
|
||||
from bec_widgets.utils.error_popups import SafeSlot as Slot
|
||||
|
||||
ansi_colors = {
|
||||
"black": "#000000",
|
||||
"red": "#CD0000",
|
||||
"green": "#00CD00",
|
||||
"brown": "#996633", # Brown, replacing the yellow
|
||||
"blue": "#0000EE",
|
||||
"magenta": "#CD00CD",
|
||||
"cyan": "#00CDCD",
|
||||
"white": "#E5E5E5",
|
||||
"brightblack": "#7F7F7F",
|
||||
"brightred": "#FF0000",
|
||||
"brightgreen": "#00FF00",
|
||||
"brightyellow": "#FFFF00",
|
||||
"brightblue": "#5C5CFF",
|
||||
"brightmagenta": "#FF00FF",
|
||||
"brightcyan": "#00FFFF",
|
||||
"brightwhite": "#FFFFFF",
|
||||
}
|
||||
|
||||
control_keys_mapping = {
|
||||
QtCore.Qt.Key_A: b"\x01", # Ctrl-A
|
||||
QtCore.Qt.Key_B: b"\x02", # Ctrl-B
|
||||
QtCore.Qt.Key_C: b"\x03", # Ctrl-C
|
||||
QtCore.Qt.Key_D: b"\x04", # Ctrl-D
|
||||
QtCore.Qt.Key_E: b"\x05", # Ctrl-E
|
||||
QtCore.Qt.Key_F: b"\x06", # Ctrl-F
|
||||
QtCore.Qt.Key_G: b"\x07", # Ctrl-G (Bell)
|
||||
QtCore.Qt.Key_H: b"\x08", # Ctrl-H (Backspace)
|
||||
QtCore.Qt.Key_I: b"\x09", # Ctrl-I (Tab)
|
||||
QtCore.Qt.Key_J: b"\x0a", # Ctrl-J (Line Feed)
|
||||
QtCore.Qt.Key_K: b"\x0b", # Ctrl-K (Vertical Tab)
|
||||
QtCore.Qt.Key_L: b"\x0c", # Ctrl-L (Form Feed)
|
||||
QtCore.Qt.Key_M: b"\x0d", # Ctrl-M (Carriage Return)
|
||||
QtCore.Qt.Key_N: b"\x0e", # Ctrl-N
|
||||
QtCore.Qt.Key_O: b"\x0f", # Ctrl-O
|
||||
QtCore.Qt.Key_P: b"\x10", # Ctrl-P
|
||||
QtCore.Qt.Key_Q: b"\x11", # Ctrl-Q
|
||||
QtCore.Qt.Key_R: b"\x12", # Ctrl-R
|
||||
QtCore.Qt.Key_S: b"\x13", # Ctrl-S
|
||||
QtCore.Qt.Key_T: b"\x14", # Ctrl-T
|
||||
QtCore.Qt.Key_U: b"\x15", # Ctrl-U
|
||||
QtCore.Qt.Key_V: b"\x16", # Ctrl-V
|
||||
QtCore.Qt.Key_W: b"\x17", # Ctrl-W
|
||||
QtCore.Qt.Key_X: b"\x18", # Ctrl-X
|
||||
QtCore.Qt.Key_Y: b"\x19", # Ctrl-Y
|
||||
QtCore.Qt.Key_Z: b"\x1a", # Ctrl-Z
|
||||
QtCore.Qt.Key_Escape: b"\x1b", # Ctrl-Escape
|
||||
QtCore.Qt.Key_Backslash: b"\x1c", # Ctrl-\
|
||||
QtCore.Qt.Key_Underscore: b"\x1f", # Ctrl-_
|
||||
}
|
||||
|
||||
normal_keys_mapping = {
|
||||
QtCore.Qt.Key_Return: b"\n",
|
||||
QtCore.Qt.Key_Space: b" ",
|
||||
QtCore.Qt.Key_Enter: b"\n",
|
||||
QtCore.Qt.Key_Tab: b"\t",
|
||||
QtCore.Qt.Key_Backspace: b"\x08",
|
||||
QtCore.Qt.Key_Home: b"\x47",
|
||||
QtCore.Qt.Key_End: b"\x4f",
|
||||
QtCore.Qt.Key_Left: b"\x02",
|
||||
QtCore.Qt.Key_Up: b"\x10",
|
||||
QtCore.Qt.Key_Right: b"\x06",
|
||||
QtCore.Qt.Key_Down: b"\x0e",
|
||||
QtCore.Qt.Key_PageUp: b"\x49",
|
||||
QtCore.Qt.Key_PageDown: b"\x51",
|
||||
QtCore.Qt.Key_F1: b"\x1b\x31",
|
||||
QtCore.Qt.Key_F2: b"\x1b\x32",
|
||||
QtCore.Qt.Key_F3: b"\x1b\x33",
|
||||
QtCore.Qt.Key_F4: b"\x1b\x34",
|
||||
QtCore.Qt.Key_F5: b"\x1b\x35",
|
||||
QtCore.Qt.Key_F6: b"\x1b\x36",
|
||||
QtCore.Qt.Key_F7: b"\x1b\x37",
|
||||
QtCore.Qt.Key_F8: b"\x1b\x38",
|
||||
QtCore.Qt.Key_F9: b"\x1b\x39",
|
||||
QtCore.Qt.Key_F10: b"\x1b\x30",
|
||||
QtCore.Qt.Key_F11: b"\x45",
|
||||
QtCore.Qt.Key_F12: b"\x46",
|
||||
}
|
||||
|
||||
|
||||
def QtKeyToAscii(event):
|
||||
"""
|
||||
Convert the Qt key event to the corresponding ASCII sequence for
|
||||
the terminal. This works fine for standard alphanumerical characters, but
|
||||
most other characters require terminal specific control sequences.
|
||||
|
||||
The conversion below works for TERM="linux" terminals.
|
||||
"""
|
||||
if sys.platform == "darwin":
|
||||
# special case for MacOS
|
||||
# /!\ Qt maps ControlModifier to CMD
|
||||
# CMD-C, CMD-V for copy/paste
|
||||
# CTRL-C and other modifiers -> key mapping
|
||||
if event.modifiers() == QtCore.Qt.MetaModifier:
|
||||
if event.key() == Qt.Key_Backspace:
|
||||
return control_keys_mapping.get(Qt.Key_W)
|
||||
return control_keys_mapping.get(event.key())
|
||||
elif event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
if event.key() == Qt.Key_C:
|
||||
# copy
|
||||
return "copy"
|
||||
elif event.key() == Qt.Key_V:
|
||||
# paste
|
||||
return "paste"
|
||||
return None
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
if event.modifiers() == QtCore.Qt.ControlModifier:
|
||||
return control_keys_mapping.get(event.key())
|
||||
else:
|
||||
return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
|
||||
|
||||
|
||||
class Screen(pyte.HistoryScreen):
|
||||
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),
|
||||
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):
|
||||
"""
|
||||
Poll Bash.
|
||||
|
||||
This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
|
||||
file descriptor of the Bash terminal.
|
||||
"""
|
||||
|
||||
# Signals to communicate with ``_TerminalWidget``.
|
||||
dataReady = pyqtSignal(object)
|
||||
processExited = pyqtSignal()
|
||||
|
||||
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, cols, rows, 10000)
|
||||
self.stream = pyte.ByteStream()
|
||||
self.stream.attach(self.screen)
|
||||
|
||||
self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
|
||||
self.notifier.activated.connect(self._fd_readable)
|
||||
|
||||
def _fd_readable(self):
|
||||
"""
|
||||
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
|
||||
# output to the GUI
|
||||
self.stream.feed(out)
|
||||
self.dataReady.emit(self.screen)
|
||||
|
||||
|
||||
class BECConsole(QtWidgets.QWidget):
|
||||
"""Container widget for the terminal text area"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "terminal"
|
||||
|
||||
prompt = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None, cols=132):
|
||||
super().__init__(parent)
|
||||
|
||||
self.term = _TerminalWidget(self, cols, rows=43)
|
||||
self.term.prompt.connect(self.prompt) # forward signal from term to this widget
|
||||
|
||||
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)
|
||||
|
||||
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._check_designer_timer = QTimer()
|
||||
self._check_designer_timer.timeout.connect(self.check_designer)
|
||||
self._check_designer_timer.start(1000)
|
||||
|
||||
def minimumSizeHint(self):
|
||||
size = self.term.sizeHint()
|
||||
size.setWidth(size.width() + self.scroll_bar.width())
|
||||
return size
|
||||
|
||||
def sizeHint(self):
|
||||
return self.minimumSizeHint()
|
||||
|
||||
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, hit_return=False):
|
||||
"""Push some text to the terminal"""
|
||||
return self.term.push(text, hit_return=hit_return)
|
||||
|
||||
def execute_command(self, command):
|
||||
self.push(command, hit_return=True)
|
||||
|
||||
def set_prompt_tokens(self, *tokens):
|
||||
"""Prepare regexp to identify prompt, based on tokens
|
||||
|
||||
Tokens are returned from get_ipython().prompts.in_prompt_tokens()
|
||||
"""
|
||||
regex_parts = []
|
||||
for token_type, token_value in tokens:
|
||||
if token_type == Token.PromptNum: # Handle dynamic prompt number
|
||||
regex_parts.append(r"[\d\?]+") # Match one or more digits or '?'
|
||||
else:
|
||||
# Escape other prompt parts (e.g., "In [", "]: ")
|
||||
if not token_value:
|
||||
regex_parts.append(".+?") # arbitrary string
|
||||
else:
|
||||
regex_parts.append(re.escape(token_value))
|
||||
|
||||
# Combine into a single regex
|
||||
prompt_pattern = "".join(regex_parts)
|
||||
self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")
|
||||
|
||||
def terminate(self, timeout=10):
|
||||
self.term.stop(timeout=timeout)
|
||||
|
||||
def send_ctrl_c(self, timeout=None):
|
||||
self.term.send_ctrl_c(timeout)
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
prompt = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent, cols=125, rows=50, **kwargs):
|
||||
# regexp to match prompt
|
||||
self._prompt_re = None
|
||||
# last prompt
|
||||
self._prompt_str = None
|
||||
# process pid
|
||||
self.pid = None
|
||||
# file descriptor to communicate with the subprocess
|
||||
self.fd = None
|
||||
self.backend = None
|
||||
# command to execute
|
||||
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._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)
|
||||
fmt = QtGui.QFontMetrics(self.font())
|
||||
char_width = fmt.width("w")
|
||||
self.setCursorWidth(char_width)
|
||||
|
||||
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
|
||||
|
||||
self.update_term_size()
|
||||
|
||||
# Start the Bash process
|
||||
self.pid, 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 send_ctrl_c(self, wait_prompt=True, timeout=None):
|
||||
"""Send CTRL-C to the process
|
||||
|
||||
If wait_prompt=True (default), wait for a new prompt after CTRL-C
|
||||
If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
|
||||
"""
|
||||
os.kill(self.pid, signal.SIGINT)
|
||||
if wait_prompt:
|
||||
timeout_error = False
|
||||
if timeout:
|
||||
|
||||
def set_timeout_error():
|
||||
nonlocal timeout_error
|
||||
timeout_error = True
|
||||
|
||||
timeout_timer = QTimer()
|
||||
timeout_timer.singleShot(timeout * 1000, set_timeout_error)
|
||||
while self._prompt_str is None:
|
||||
QApplication.instance().process_events()
|
||||
if timeout_error:
|
||||
raise TimeoutError(
|
||||
f"CTRL-C: could not get back to prompt after {timeout} seconds."
|
||||
)
|
||||
|
||||
def _is_running(self):
|
||||
if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
|
||||
return True
|
||||
return False
|
||||
|
||||
def stop(self, kill=True, timeout=None):
|
||||
"""Stop the running process
|
||||
|
||||
SIGTERM is the default signal for terminating processes.
|
||||
|
||||
If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
|
||||
"""
|
||||
# try to exit gracefully
|
||||
os.kill(self.pid, signal.SIGTERM)
|
||||
|
||||
# wait until process is truly dead
|
||||
t0 = time.perf_counter()
|
||||
while self._is_running():
|
||||
time.sleep(1)
|
||||
if timeout is not None and time.perf_counter() - t0 > timeout:
|
||||
# still alive after 'timeout' seconds
|
||||
if kill:
|
||||
# send SIGKILL and make a last check in loop
|
||||
os.kill(self.pid, signal.SIGKILL)
|
||||
kill = False
|
||||
else:
|
||||
# still running after timeout...
|
||||
raise TimeoutError(
|
||||
f"Could not terminate process with pid: {self.pid} within timeout"
|
||||
)
|
||||
self.process_exited()
|
||||
|
||||
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):
|
||||
"""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 sizeHint(self):
|
||||
return self.minimumSizeHint()
|
||||
|
||||
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
|
||||
nlines = old["value"] - value
|
||||
# history ratio gives prev_page == 1 line
|
||||
for i in range(nlines):
|
||||
self.backend.screen.prev_page()
|
||||
else:
|
||||
# scroll down
|
||||
nlines = value - old["value"]
|
||||
for i in range(nlines):
|
||||
self.backend.screen.next_page()
|
||||
old["value"] = value
|
||||
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
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
and event.key() == QtCore.Qt.Key_D
|
||||
):
|
||||
return None
|
||||
|
||||
code = QtKeyToAscii(event)
|
||||
if code == "copy":
|
||||
# MacOS only: CMD-C handling
|
||||
self.copy()
|
||||
elif code == "paste":
|
||||
# MacOS only: CMD-V handling
|
||||
self._push_clipboard()
|
||||
elif code is not None:
|
||||
self.write(code)
|
||||
|
||||
def push(self, text, hit_return=False):
|
||||
"""
|
||||
Write 'text' to terminal
|
||||
"""
|
||||
self.write(text.encode("utf-8"))
|
||||
if hit_return:
|
||||
self.write(b"\n")
|
||||
|
||||
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
|
||||
if "opy" in action.text():
|
||||
# redefine text without shortcut
|
||||
# since it probably clashes with control codes (like CTRL-C etc)
|
||||
action.setText("Copy")
|
||||
continue
|
||||
if "aste" in action.text():
|
||||
# redefine text without shortcut
|
||||
action.setText("Paste")
|
||||
# paste -> have to insert with self.push
|
||||
action.triggered.connect(self._push_clipboard)
|
||||
continue
|
||||
menu.removeAction(action)
|
||||
menu.exec_(event.globalPos())
|
||||
|
||||
def _push_clipboard(self):
|
||||
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()
|
||||
if clipboard.supportsSelection():
|
||||
self.push(clipboard.text(QClipboard.Selection))
|
||||
return None
|
||||
elif event.button() == Qt.LeftButton:
|
||||
# left button click
|
||||
textCursor = self.textCursor()
|
||||
if textCursor.selectedText():
|
||||
# mouse was used to select text -> nothing to do
|
||||
pass
|
||||
else:
|
||||
# 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 redraw_screen(self):
|
||||
"""
|
||||
Render the screen as formatted text into the widget.
|
||||
"""
|
||||
screen = self.backend.screen
|
||||
|
||||
# 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 screen.dirty:
|
||||
line = text = ""
|
||||
style = old_style = ""
|
||||
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:
|
||||
line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
|
||||
else:
|
||||
line += html.escape(text, quote=True)
|
||||
text = ""
|
||||
old_style = style
|
||||
text += ch.data
|
||||
if style:
|
||||
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>")
|
||||
|
||||
if self._prompt_re is not None:
|
||||
text_buf = self.toPlainText()
|
||||
prompt = self._prompt_re.search(text_buf)
|
||||
if prompt is None:
|
||||
if self._prompt_str:
|
||||
self.prompt.emit(False)
|
||||
self._prompt_str = None
|
||||
else:
|
||||
prompt_str = prompt.string.rstrip()
|
||||
if prompt_str != self._prompt_str:
|
||||
self._prompt_str = prompt_str
|
||||
self.prompt.emit(True)
|
||||
|
||||
# did updates, all clean
|
||||
screen.dirty.clear()
|
||||
|
||||
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)
|
||||
|
||||
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.redraw_screen()
|
||||
|
||||
def fork_shell(self):
|
||||
"""
|
||||
Fork the current process and execute bec in shell.
|
||||
"""
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except (IOError, OSError):
|
||||
return False
|
||||
if pid == 0:
|
||||
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.execvp(cmd[0], cmd)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
os._exit(0)
|
||||
else:
|
||||
# We are in the parent process.
|
||||
# Set file control
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
||||
return pid, fd
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
|
||||
# Create the Qt application and console.
|
||||
app = QtWidgets.QApplication([])
|
||||
mainwin = QtWidgets.QMainWindow()
|
||||
title = "BECConsole"
|
||||
mainwin.setWindowTitle(title)
|
||||
|
||||
console = BECConsole(mainwin)
|
||||
mainwin.setCentralWidget(console)
|
||||
|
||||
def check_prompt(at_prompt):
|
||||
if at_prompt:
|
||||
print("NEW PROMPT")
|
||||
else:
|
||||
print("EXECUTING SOMETHING...")
|
||||
|
||||
console.set_prompt_tokens(
|
||||
(Token.OutPromptNum, "•"),
|
||||
(Token.Prompt, ""), # will match arbitrary string,
|
||||
(Token.Prompt, " ["),
|
||||
(Token.PromptNum, "3"),
|
||||
(Token.Prompt, "/"),
|
||||
(Token.PromptNum, "1"),
|
||||
(Token.Prompt, "] "),
|
||||
(Token.Prompt, "❯❯"),
|
||||
)
|
||||
console.prompt.connect(check_prompt)
|
||||
console.start()
|
||||
|
||||
# Show widget and launch Qt's event loop.
|
||||
mainwin.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -1 +0,0 @@
|
||||
{'files': ['console.py']}
|
||||
@@ -88,6 +88,8 @@ class Image(PlotBase):
|
||||
"auto_range_x.setter",
|
||||
"auto_range_y",
|
||||
"auto_range_y.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# ImageView Specific Settings
|
||||
"color_map",
|
||||
"color_map.setter",
|
||||
|
||||
@@ -91,6 +91,8 @@ class MultiWaveform(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# MultiWaveform Specific RPC Access
|
||||
"highlighted_index",
|
||||
"highlighted_index.setter",
|
||||
|
||||
@@ -116,6 +116,7 @@ class PlotBase(BECWidget, QWidget):
|
||||
self._user_y_label = ""
|
||||
self._y_label_suffix = ""
|
||||
self._y_axis_units = ""
|
||||
self._minimal_crosshair_precision = 3
|
||||
|
||||
# Plot Indicator Items
|
||||
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
|
||||
@@ -978,7 +979,9 @@ class PlotBase(BECWidget, QWidget):
|
||||
def hook_crosshair(self) -> None:
|
||||
"""Hook the crosshair to all plots."""
|
||||
if self.crosshair is None:
|
||||
self.crosshair = Crosshair(self.plot_item, precision=3)
|
||||
self.crosshair = Crosshair(
|
||||
self.plot_item, min_precision=self._minimal_crosshair_precision
|
||||
)
|
||||
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
|
||||
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
|
||||
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
|
||||
@@ -1006,6 +1009,29 @@ class PlotBase(BECWidget, QWidget):
|
||||
|
||||
self.unhook_crosshair()
|
||||
|
||||
@SafeProperty(
|
||||
int, doc="Minimum decimal places for crosshair when dynamic precision is enabled."
|
||||
)
|
||||
def minimal_crosshair_precision(self) -> int:
|
||||
"""
|
||||
Minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
"""
|
||||
return self._minimal_crosshair_precision
|
||||
|
||||
@minimal_crosshair_precision.setter
|
||||
def minimal_crosshair_precision(self, value: int):
|
||||
"""
|
||||
Set the minimum decimal places for crosshair when dynamic precision is enabled.
|
||||
|
||||
Args:
|
||||
value(int): The minimum decimal places to set.
|
||||
"""
|
||||
value_int = max(0, int(value))
|
||||
self._minimal_crosshair_precision = value_int
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.min_precision = value_int
|
||||
self.property_changed.emit("minimal_crosshair_precision", value_int)
|
||||
|
||||
@SafeSlot()
|
||||
def reset(self) -> None:
|
||||
"""Reset the plot widget."""
|
||||
|
||||
@@ -82,6 +82,8 @@ class ScatterWaveform(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# Scatter Waveform Specific RPC Access
|
||||
"main_curve",
|
||||
"color_map",
|
||||
|
||||
@@ -60,6 +60,7 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_grid,
|
||||
self.ui.inner_axes,
|
||||
self.ui.outer_axes,
|
||||
self.ui.minimal_crosshair_precision,
|
||||
]:
|
||||
WidgetIO.connect_widget_change_signal(widget, self.set_property)
|
||||
|
||||
@@ -121,6 +122,7 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_max,
|
||||
self.ui.y_log,
|
||||
self.ui.y_grid,
|
||||
self.ui.minimal_crosshair_precision,
|
||||
]:
|
||||
property_name = widget.objectName()
|
||||
value = getattr(self.target_widget, property_name)
|
||||
@@ -144,6 +146,7 @@ class AxisSettings(SettingWidget):
|
||||
self.ui.y_grid,
|
||||
self.ui.outer_axes,
|
||||
self.ui.inner_axes,
|
||||
self.ui.minimal_crosshair_precision,
|
||||
]:
|
||||
property_name = widget.objectName()
|
||||
value = WidgetIO.get_value(widget)
|
||||
|
||||
@@ -14,97 +14,6 @@
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="2">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="ToggleSwitch" name="x_log">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="ToggleSwitch" name="x_grid">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BECSpinBox" name="x_min"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="BECSpinBox" name="x_max"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="2" colspan="2">
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
@@ -179,6 +88,87 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="3">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0" colspan="2">
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_4">
|
||||
<item row="5" column="0">
|
||||
<widget class="QLabel" name="x_grid_label">
|
||||
<property name="text">
|
||||
<string>Grid</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="2">
|
||||
<widget class="ToggleSwitch" name="x_log">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="x_max_label">
|
||||
<property name="text">
|
||||
<string>Max</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="2">
|
||||
<widget class="ToggleSwitch" name="x_grid">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="x_scale_label">
|
||||
<property name="text">
|
||||
<string>Log</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0" colspan="2">
|
||||
<widget class="QLabel" name="x_min_label">
|
||||
<property name="text">
|
||||
<string>Min</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="x_label_label">
|
||||
<property name="text">
|
||||
<string>Label</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLineEdit" name="x_label"/>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="BECSpinBox" name="x_min"/>
|
||||
</item>
|
||||
<item row="2" column="2">
|
||||
<widget class="BECSpinBox" name="x_max"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0" colspan="4">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
@@ -191,8 +181,41 @@
|
||||
<item>
|
||||
<widget class="QLineEdit" name="title"/>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="minimal_crosshair_precision">
|
||||
<property name="toolTip">
|
||||
<string>Minimal Crosshair Precision</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>20</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
|
||||
@@ -6,15 +6,84 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>241</width>
|
||||
<height>526</height>
|
||||
<width>250</width>
|
||||
<height>612</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="4" column="0" colspan="2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="general_box">
|
||||
<property name="title">
|
||||
<string>General</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="4" column="0">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="1">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QLineEdit" name="title"/>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="plot_title_label">
|
||||
<property name="text">
|
||||
<string>Plot Title</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="toolTip">
|
||||
<string>Minimal Crosshair Precision</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Precision</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QSpinBox" name="minimal_crosshair_precision">
|
||||
<property name="toolTip">
|
||||
<string>Minimal Crosshair Precision</string>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignmentFlag::AlignCenter</set>
|
||||
</property>
|
||||
<property name="maximum">
|
||||
<number>20</number>
|
||||
</property>
|
||||
<property name="value">
|
||||
<number>3</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="x_axis_box">
|
||||
<property name="title">
|
||||
<string>X Axis</string>
|
||||
@@ -81,28 +150,7 @@
|
||||
</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="title"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_outer_axes">
|
||||
<property name="text">
|
||||
<string>Outer Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0" colspan="2">
|
||||
<item>
|
||||
<widget class="QGroupBox" name="y_axis_box">
|
||||
<property name="title">
|
||||
<string>Y Axis</string>
|
||||
@@ -169,23 +217,6 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="ToggleSwitch" name="outer_axes">
|
||||
<property name="checked" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Inner Axes</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="ToggleSwitch" name="inner_axes"/>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
|
||||
@@ -9,8 +9,19 @@ import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import Field, ValidationError, field_validator
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
|
||||
@@ -33,6 +44,11 @@ class WaveformConfig(ConnectionConfig):
|
||||
color_palette: str | None = Field(
|
||||
"plasma", description="The color palette of the figure widget.", validate_default=True
|
||||
)
|
||||
max_dataset_size_mb: float = Field(
|
||||
10,
|
||||
description="Maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.",
|
||||
validate_default=True,
|
||||
)
|
||||
|
||||
model_config: dict = {"validate_assignment": True}
|
||||
_validate_color_palette = field_validator("color_palette")(Colors.validate_color_map)
|
||||
@@ -86,6 +102,8 @@ class Waveform(PlotBase):
|
||||
"y_log.setter",
|
||||
"legend_label_size",
|
||||
"legend_label_size.setter",
|
||||
"minimal_crosshair_precision",
|
||||
"minimal_crosshair_precision.setter",
|
||||
# Waveform Specific RPC Access
|
||||
"curves",
|
||||
"x_mode",
|
||||
@@ -94,6 +112,12 @@ class Waveform(PlotBase):
|
||||
"x_entry.setter",
|
||||
"color_palette",
|
||||
"color_palette.setter",
|
||||
"skip_large_dataset_warning",
|
||||
"skip_large_dataset_warning.setter",
|
||||
"skip_large_dataset_check",
|
||||
"skip_large_dataset_check.setter",
|
||||
"max_dataset_size_mb",
|
||||
"max_dataset_size_mb.setter",
|
||||
"plot",
|
||||
"add_dap_curve",
|
||||
"remove_curve",
|
||||
@@ -142,6 +166,7 @@ class Waveform(PlotBase):
|
||||
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
|
||||
|
||||
# Scan data
|
||||
self._scan_done = True # means scan is not running
|
||||
self.old_scan_id = None
|
||||
self.scan_id = None
|
||||
self.scan_item = None
|
||||
@@ -161,6 +186,10 @@ class Waveform(PlotBase):
|
||||
self._init_curve_dialog()
|
||||
self.curve_settings_dialog = None
|
||||
|
||||
# Large‑dataset guard
|
||||
self._skip_large_dataset_warning = False # session flag
|
||||
self._skip_large_dataset_check = False # per-plot flag, to skip the warning for this plot
|
||||
|
||||
# Scan status update loop
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
@@ -559,6 +588,59 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
return [item for item in self.plot_item.curves if isinstance(item, Curve)]
|
||||
|
||||
@SafeProperty(bool)
|
||||
def skip_large_dataset_check(self) -> bool:
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
return self._skip_large_dataset_check
|
||||
|
||||
@skip_large_dataset_check.setter
|
||||
def skip_large_dataset_check(self, value: bool):
|
||||
"""
|
||||
Set whether to skip the large dataset warning when fetching async data.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to skip the large dataset warning.
|
||||
"""
|
||||
self._skip_large_dataset_check = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def skip_large_dataset_warning(self) -> bool:
|
||||
"""
|
||||
Whether to skip the large dataset warning when fetching async data.
|
||||
"""
|
||||
return self._skip_large_dataset_warning
|
||||
|
||||
@skip_large_dataset_warning.setter
|
||||
def skip_large_dataset_warning(self, value: bool):
|
||||
"""
|
||||
Set whether to skip the large dataset warning when fetching async data.
|
||||
|
||||
Args:
|
||||
value(bool): Whether to skip the large dataset warning.
|
||||
"""
|
||||
self._skip_large_dataset_warning = value
|
||||
|
||||
@SafeProperty(float)
|
||||
def max_dataset_size_mb(self) -> float:
|
||||
"""
|
||||
The maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
"""
|
||||
return self.config.max_dataset_size_mb
|
||||
|
||||
@max_dataset_size_mb.setter
|
||||
def max_dataset_size_mb(self, value: float):
|
||||
"""
|
||||
Set the maximum dataset size (in MB) permitted when fetching async data from history before prompting the user.
|
||||
|
||||
Args:
|
||||
value(float): The maximum dataset size in MB.
|
||||
"""
|
||||
if value <= 0:
|
||||
raise ValueError("Maximum dataset size must be greater than 0.")
|
||||
self.config.max_dataset_size_mb = value
|
||||
|
||||
################################################################################
|
||||
# High Level methods for API
|
||||
################################################################################
|
||||
@@ -805,8 +887,6 @@ class Waveform(PlotBase):
|
||||
if config.source == "device":
|
||||
if self.scan_item is None:
|
||||
self.update_with_scan_history(-1)
|
||||
if curve in self._async_curves:
|
||||
self._setup_async_curve(curve)
|
||||
self.async_signal_update.emit()
|
||||
self.sync_signal_update.emit()
|
||||
if config.source == "dap":
|
||||
@@ -1054,8 +1134,8 @@ class Waveform(PlotBase):
|
||||
meta(dict): The message metadata.
|
||||
"""
|
||||
self.sync_signal_update.emit()
|
||||
status = msg.get("done")
|
||||
if status:
|
||||
self._scan_done = msg.get("done")
|
||||
if self._scan_done:
|
||||
QTimer.singleShot(100, self.update_sync_curves)
|
||||
QTimer.singleShot(300, self.update_sync_curves)
|
||||
|
||||
@@ -1133,9 +1213,11 @@ class Waveform(PlotBase):
|
||||
if access_key == "val": # live access
|
||||
device_data = data.get(device_name, {}).get(device_entry, {}).get(access_key, None)
|
||||
else: # history access
|
||||
device_data = (
|
||||
data.get(device_name, {}).get(device_entry, {}).read().get("value", None)
|
||||
)
|
||||
dataset_obj = data.get(device_name, {})
|
||||
if self._skip_large_dataset_check is False:
|
||||
if not self._check_dataset_size_and_confirm(dataset_obj, device_entry):
|
||||
continue # user declined to load; skip this curve
|
||||
device_data = dataset_obj.get(device_entry, {}).read().get("value", None)
|
||||
|
||||
# if shape is 2D cast it into 1D and take the last waveform
|
||||
if len(np.shape(device_data)) > 1:
|
||||
@@ -1579,6 +1661,8 @@ class Waveform(PlotBase):
|
||||
dev_name = curve.config.signal.name
|
||||
if dev_name in readout_priority_async:
|
||||
self._async_curves.append(curve)
|
||||
if hasattr(self.scan_item, "live_data"):
|
||||
self._setup_async_curve(curve)
|
||||
found_async = True
|
||||
elif dev_name in readout_priority_sync:
|
||||
self._sync_curves.append(curve)
|
||||
@@ -1655,6 +1739,106 @@ class Waveform(PlotBase):
|
||||
################################################################################
|
||||
# Utility Methods
|
||||
################################################################################
|
||||
|
||||
# Large dataset handling helpers
|
||||
def _check_dataset_size_and_confirm(self, dataset_obj, device_entry: str) -> bool:
|
||||
"""
|
||||
Check the size of the dataset and confirm with the user if it exceeds the limit.
|
||||
|
||||
Args:
|
||||
dataset_obj: The dataset object containing the information.
|
||||
device_entry( str): The specific device entry to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the dataset is within the size limit or user confirmed to load it,
|
||||
False if the dataset exceeds the size limit and user declined to load it.
|
||||
"""
|
||||
try:
|
||||
info = dataset_obj._info
|
||||
mem_bytes = info.get(device_entry, {}).get("value", {}).get("mem_size", 0)
|
||||
# Fallback – grab first entry if lookup failed
|
||||
if mem_bytes == 0 and info:
|
||||
first_key = next(iter(info))
|
||||
mem_bytes = info[first_key]["value"]["mem_size"]
|
||||
size_mb = mem_bytes / (1024 * 1024)
|
||||
print(f"Dataset size: {size_mb:.1f} MB")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(f"Unable to evaluate dataset size: {exc}")
|
||||
return True
|
||||
|
||||
if size_mb <= self.config.max_dataset_size_mb:
|
||||
return True
|
||||
logger.warning(
|
||||
f"Attempt to load large dataset: {size_mb:.1f} MB "
|
||||
f"(limit {self.config.max_dataset_size_mb} MB)"
|
||||
)
|
||||
if self._skip_large_dataset_warning:
|
||||
logger.info("Skipping large dataset warning dialog.")
|
||||
return False
|
||||
return self._confirm_large_dataset(size_mb)
|
||||
|
||||
def _confirm_large_dataset(self, size_mb: float) -> bool:
|
||||
"""
|
||||
Confirm with the user whether to load a large dataset with dialog popup.
|
||||
Also allows the user to adjust the maximum dataset size limit and if user
|
||||
wants to see this popup again during session.
|
||||
|
||||
Args:
|
||||
size_mb(float): Size of the dataset in MB.
|
||||
|
||||
Returns:
|
||||
bool: True if the user confirmed to load the dataset, False otherwise.
|
||||
"""
|
||||
if self._skip_large_dataset_warning:
|
||||
return True
|
||||
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Large dataset detected")
|
||||
main_dialog_layout = QVBoxLayout(dialog)
|
||||
|
||||
# Limit adjustment widgets
|
||||
limit_adjustment_layout = QHBoxLayout()
|
||||
limit_adjustment_layout.addWidget(QLabel("New limit (MB):"))
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0.001, 4096)
|
||||
spin.setDecimals(3)
|
||||
spin.setSingleStep(0.01)
|
||||
spin.setValue(self.config.max_dataset_size_mb)
|
||||
spin.valueChanged.connect(lambda value: setattr(self.config, "max_dataset_size_mb", value))
|
||||
limit_adjustment_layout.addWidget(spin)
|
||||
|
||||
# Don't show again checkbox
|
||||
checkbox = QCheckBox("Don't show this again for this session")
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.Yes | QDialogButtonBox.No, Qt.Horizontal, dialog
|
||||
)
|
||||
buttons.accepted.connect(dialog.accept) # Yes
|
||||
buttons.rejected.connect(dialog.reject) # No
|
||||
|
||||
# widget layout
|
||||
main_dialog_layout.addWidget(
|
||||
QLabel(
|
||||
f"The selected dataset is {size_mb:.1f} MB which exceeds the "
|
||||
f"current limit of {self.config.max_dataset_size_mb} MB.\n"
|
||||
)
|
||||
)
|
||||
main_dialog_layout.addLayout(limit_adjustment_layout)
|
||||
main_dialog_layout.addWidget(checkbox)
|
||||
main_dialog_layout.addWidget(QLabel("Would you like to display dataset anyway?"))
|
||||
main_dialog_layout.addWidget(buttons)
|
||||
|
||||
result = dialog.exec() # modal; waits for user choice
|
||||
|
||||
# Respect the “don't show again” checkbox for *either* choice
|
||||
if checkbox.isChecked():
|
||||
self._skip_large_dataset_warning = True
|
||||
|
||||
if result == QDialog.Accepted:
|
||||
self.config.max_dataset_size_mb = spin.value()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _ensure_str_list(self, entries: list | tuple | np.ndarray):
|
||||
"""
|
||||
Convert a variety of possible inputs (string, bytes, list/tuple/ndarray of either)
|
||||
@@ -1785,7 +1969,7 @@ class DemoApp(QMainWindow): # pragma: no cover
|
||||
self.setCentralWidget(self.main_widget)
|
||||
|
||||
self.waveform_popup = Waveform(popups=True)
|
||||
self.waveform_popup.plot(y_name="monitor_async")
|
||||
self.waveform_popup.plot(y_name="waveform")
|
||||
|
||||
self.waveform_side = Waveform(popups=False)
|
||||
self.waveform_side.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
|
||||
|
||||
@@ -11,12 +11,11 @@ from re import Pattern
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from bec_lib.client import BECClient
|
||||
from bec_lib.connector import ConnectorBase
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import LogLevel, bec_logger
|
||||
from bec_lib.messages import LogMessage, StatusMessage
|
||||
from PySide6.QtCore import QObject
|
||||
from qtpy.QtCore import QDateTime, Qt, Signal
|
||||
from pyqtgraph import SignalProxy
|
||||
from qtpy.QtCore import QDateTime, QObject, Qt, Signal
|
||||
from qtpy.QtGui import QFont
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -35,6 +34,7 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.colors import get_theme_palette, set_theme
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.editors.text_box.text_box import TextBox
|
||||
@@ -69,22 +69,22 @@ DEFAULT_LOG_COLORS = {
|
||||
}
|
||||
|
||||
|
||||
class BecLogsQueue(QObject):
|
||||
class BecLogsQueue(BECConnector, QObject):
|
||||
"""Manages getting logs from BEC Redis and formatting them for display"""
|
||||
|
||||
RPC = False
|
||||
new_message = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None,
|
||||
conn: ConnectorBase,
|
||||
maxlen: int = 1000,
|
||||
line_formatter: LineFormatter = noop_format,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._timestamp_start: QDateTime | None = None
|
||||
self._timestamp_end: QDateTime | None = None
|
||||
self._conn = conn
|
||||
self._max_length = maxlen
|
||||
self._data: deque[LogMessage] = deque([], self._max_length)
|
||||
self._display_queue: deque[str] = deque([], self._max_length)
|
||||
@@ -92,20 +92,26 @@ class BecLogsQueue(QObject):
|
||||
self._search_query: Pattern | str | None = None
|
||||
self._selected_services: set[str] | None = None
|
||||
self._set_formatter_and_update_filter(line_formatter)
|
||||
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
# instance attribute still accessible after c++ object is deleted, so the callback can be unregistered
|
||||
self.bec_dispatcher.connect_slot(self._process_incoming_log_msg, MessageEndpoints.log())
|
||||
|
||||
def unsub_from_redis(self):
|
||||
def cleanup(self, *_):
|
||||
"""Stop listening to the Redis log stream"""
|
||||
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self._process_incoming_log_msg, [MessageEndpoints.log()]
|
||||
)
|
||||
|
||||
def _process_incoming_log_msg(self, msg: dict):
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
|
||||
try:
|
||||
_msg: LogMessage = msg["data"]
|
||||
_msg = LogMessage(**msg)
|
||||
self._data.append(_msg)
|
||||
if self.filter is None or self.filter(_msg):
|
||||
self._display_queue.append(self._line_formatter(_msg))
|
||||
self.new_message.emit()
|
||||
except Exception as e:
|
||||
if "Internal C++ object (BecLogsQueue) already deleted." in e.args:
|
||||
return
|
||||
logger.warning(f"Error in LogPanel incoming message callback: {e}")
|
||||
|
||||
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
|
||||
@@ -202,7 +208,7 @@ class BecLogsQueue(QObject):
|
||||
"""Fetch all available messages from Redis"""
|
||||
self._data = deque(
|
||||
item["data"]
|
||||
for item in self._conn.xread(
|
||||
for item in self.bec_dispatcher.client.connector.xread(
|
||||
MessageEndpoints.log().endpoint, from_start=True, count=self._max_length
|
||||
)
|
||||
)
|
||||
@@ -396,7 +402,6 @@ class LogPanel(TextBox):
|
||||
"""Displays a log panel"""
|
||||
|
||||
ICON_NAME = "terminal"
|
||||
_new_messages = Signal()
|
||||
service_list_update = Signal(dict, set)
|
||||
|
||||
def __init__(
|
||||
@@ -407,17 +412,17 @@ class LogPanel(TextBox):
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the LogPanel widget."""
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
super().__init__(parent=parent, client=client, config={"text": ""}, **kwargs)
|
||||
self._update_colors()
|
||||
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
|
||||
self._log_manager = BecLogsQueue(
|
||||
parent,
|
||||
self.client.connector, # type: ignore
|
||||
line_formatter=partial(simple_color_format, colors=self._colors),
|
||||
parent=self, line_formatter=partial(simple_color_format, colors=self._colors)
|
||||
)
|
||||
self._proxy_update = SignalProxy(
|
||||
self._log_manager.new_message, rateLimit=1, slot=self._on_append
|
||||
)
|
||||
self._log_manager.new_message.connect(self._new_messages)
|
||||
|
||||
self.toolbar = LogPanelToolbar(parent=parent)
|
||||
self.toolbar = LogPanelToolbar(parent=self)
|
||||
self.toolbar_area = QScrollArea()
|
||||
self.toolbar_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.toolbar_area.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
|
||||
@@ -431,7 +436,6 @@ class LogPanel(TextBox):
|
||||
self.toolbar.search_textbox.returnPressed.connect(self._on_re_update)
|
||||
self.toolbar.regex_enabled.checkStateChanged.connect(self._on_re_update)
|
||||
self.toolbar.filter_level_dropdown.currentTextChanged.connect(self._set_level_filter)
|
||||
self._new_messages.connect(self._on_append)
|
||||
|
||||
self.toolbar.timerange_button.clicked.connect(self._choose_datetime)
|
||||
self._service_status.services_update.connect(self._update_service_list)
|
||||
@@ -483,10 +487,10 @@ class LogPanel(TextBox):
|
||||
self.set_html_text(self._log_manager.display_all())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_append(self):
|
||||
self._cursor_to_end()
|
||||
@SafeSlot(verify_sender=True)
|
||||
def _on_append(self, *_):
|
||||
self.text_box_text_edit.insertHtml(self._log_manager.format_new())
|
||||
self._cursor_to_end()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_clear(self):
|
||||
@@ -529,9 +533,8 @@ class LogPanel(TextBox):
|
||||
|
||||
def cleanup(self):
|
||||
self._service_status.cleanup()
|
||||
self._log_manager.unsub_from_redis()
|
||||
self._log_manager.new_message.disconnect(self._new_messages)
|
||||
self._new_messages.disconnect(self._on_append)
|
||||
self._log_manager.cleanup()
|
||||
self._log_manager.deleteLater()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ def main(): # pragma: no cover
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.editors.console.console_plugin import BECConsolePlugin
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label_plugin import SignalLabelPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(BECConsolePlugin())
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLabelPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
456
bec_widgets/widgets/utility/signal_label/signal_label.py
Normal file
456
bec_widgets/widgets/utility/signal_label/signal_label.py
Normal file
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bec_lib.device import Device, Signal
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Signal as QSignal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.ophyd_kind_util import Kind
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import (
|
||||
DeviceInputConfig,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
|
||||
DeviceLineEdit,
|
||||
)
|
||||
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_lib.client import BECClient
|
||||
|
||||
|
||||
class ChoiceDialog(QDialog):
|
||||
accepted_output = QSignal(str, str)
|
||||
|
||||
CONNECTION_ERROR_STR = "Error: client is not connected!"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
config: ConnectionConfig | None = None,
|
||||
client: BECClient | None = None,
|
||||
show_hinted: bool = True,
|
||||
show_normal: bool = False,
|
||||
show_config: bool = False,
|
||||
):
|
||||
if not client or not client.started:
|
||||
self._display_error()
|
||||
return
|
||||
super().__init__(parent=parent)
|
||||
self.setWindowTitle("Choose device and signal...")
|
||||
self._accent_colors = get_accent_colors()
|
||||
|
||||
layout = QHBoxLayout()
|
||||
|
||||
config_dict = config.model_dump() if config is not None else {}
|
||||
self._device_config = DeviceInputConfig.model_validate(config_dict)
|
||||
self._signal_config = DeviceSignalInputBaseConfig.model_validate(config_dict)
|
||||
self._device_field = DeviceLineEdit(
|
||||
config=self._device_config, parent=parent, client=client
|
||||
)
|
||||
self._signal_field = SignalComboBox(
|
||||
config=self._signal_config,
|
||||
device=self._signal_config.device,
|
||||
parent=parent,
|
||||
client=client,
|
||||
)
|
||||
layout.addWidget(self._device_field)
|
||||
layout.addWidget(self._signal_field)
|
||||
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.accept)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
|
||||
self._signal_field.include_hinted_signals = show_hinted
|
||||
self._signal_field.include_normal_signals = show_normal
|
||||
self._signal_field.include_config_signals = show_config
|
||||
|
||||
self.setLayout(layout)
|
||||
self._device_field.textChanged.connect(self._update_device)
|
||||
self._device_field.setText(config.device if config is not None else "")
|
||||
|
||||
def _display_error(self):
|
||||
try:
|
||||
super().__init__()
|
||||
except Exception:
|
||||
...
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(QLabel(self.CONNECTION_ERROR_STR))
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.Cancel)
|
||||
self.button_box.accepted.connect(self.reject)
|
||||
layout.addWidget(self.button_box)
|
||||
self.setLayout(layout)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _update_device(self, device: str):
|
||||
if device in self._device_field.dev:
|
||||
self._device_field.set_device(device)
|
||||
self._signal_field.set_device(device)
|
||||
self._device_field.setStyleSheet(
|
||||
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}"
|
||||
)
|
||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
|
||||
else:
|
||||
self._device_field.setStyleSheet(
|
||||
f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}"
|
||||
)
|
||||
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self._signal_field.clear()
|
||||
|
||||
def accept(self):
|
||||
self.accepted_output.emit(self._device_field.text(), self._signal_field.currentText())
|
||||
return super().accept()
|
||||
|
||||
|
||||
class SignalLabel(BECWidget, QWidget):
|
||||
|
||||
ICON_NAME = "scoreboard"
|
||||
RPC = True
|
||||
PLUGIN = True
|
||||
|
||||
USER_ACCESS = [
|
||||
"custom_label",
|
||||
"custom_units",
|
||||
"custom_label.setter",
|
||||
"custom_units.setter",
|
||||
"decimal_places",
|
||||
"decimal_places.setter",
|
||||
"show_default_units",
|
||||
"show_default_units.setter",
|
||||
"show_select_button",
|
||||
"show_select_button.setter",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
client: BECClient | None = None,
|
||||
device: str | None = None,
|
||||
signal: str | None = None,
|
||||
show_select_button: bool = True,
|
||||
show_default_units: bool = False,
|
||||
custom_label: str = "",
|
||||
custom_units: str = "",
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the SignalLabel widget.
|
||||
|
||||
Args:
|
||||
parent (QWidget, optional): The parent widget. Defaults to None.
|
||||
client (BECClient, optional): The BEC client. Defaults to None.
|
||||
device (str, optional): The device name. Defaults to None.
|
||||
signal (str, optional): The signal name. Defaults to None.
|
||||
selection_dialog_config (DeviceSignalInputBaseConfig | dict, optional): Configuration for the signal selection dialog.
|
||||
show_select_button (bool, optional): Whether to show the select button. Defaults to True.
|
||||
show_default_units (bool, optional): Whether to show default units. Defaults to False.
|
||||
custom_label (str, optional): Custom label for the widget. Defaults to "".
|
||||
custom_units (str, optional): Custom units for the widget. Defaults to "".
|
||||
"""
|
||||
self._config = DeviceSignalInputBaseConfig(default=signal, device=device)
|
||||
super().__init__(parent=parent, client=client, **kwargs)
|
||||
|
||||
self._device = device
|
||||
self._signal = signal
|
||||
|
||||
self._custom_label: str = custom_label
|
||||
self._custom_units: str = custom_units
|
||||
self._show_default_units: bool = show_default_units
|
||||
self._decimal_places = 3
|
||||
|
||||
self._show_hinted_signals: bool = True
|
||||
self._show_normal_signals: bool = False
|
||||
self._show_config_signals: bool = False
|
||||
|
||||
self._outer_layout = QHBoxLayout()
|
||||
self._layout = QHBoxLayout()
|
||||
self._outer_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._outer_layout)
|
||||
|
||||
self._label = QGroupBox(custom_label)
|
||||
self._outer_layout.addWidget(self._label)
|
||||
self._update_label()
|
||||
self._label.setLayout(self._layout)
|
||||
|
||||
self._value: str = ""
|
||||
self._display = QLabel()
|
||||
self._layout.addWidget(self._display)
|
||||
|
||||
self._select_button = QToolButton()
|
||||
self._select_button.setIcon(material_icon(icon_name="settings", size=(20, 20)))
|
||||
self._show_select_button: bool = show_select_button
|
||||
self._layout.addWidget(self._select_button)
|
||||
self._display.setMinimumHeight(self._select_button.sizeHint().height())
|
||||
self.show_select_button = self._show_select_button
|
||||
|
||||
self._select_button.clicked.connect(self.show_choice_dialog)
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self._connected: bool = False
|
||||
self.connect_device()
|
||||
|
||||
def _create_dialog(self):
|
||||
return ChoiceDialog(
|
||||
config=self._config,
|
||||
parent=self,
|
||||
client=self.client,
|
||||
show_config=self.show_config_signals,
|
||||
show_normal=self.show_normal_signals,
|
||||
show_hinted=self.show_hinted_signals,
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def _process_dialog(self, device: str, signal: str):
|
||||
self.disconnect_device()
|
||||
self.device = device
|
||||
self.signal = signal
|
||||
self._update_label()
|
||||
self.connect_device()
|
||||
|
||||
def show_choice_dialog(self):
|
||||
dialog = self._create_dialog()
|
||||
dialog.accepted_output.connect(self._process_dialog)
|
||||
dialog.open()
|
||||
return dialog
|
||||
|
||||
def connect_device(self):
|
||||
"""Subscribe to the Redis topic for the device to display"""
|
||||
if not self._connected and self._device and self._device in self.dev:
|
||||
self._connected = True
|
||||
self._readback_endpoint = MessageEndpoints.device_readback(self._device)
|
||||
self.bec_dispatcher.connect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
self._manual_read()
|
||||
self.set_display_value(self._value)
|
||||
|
||||
def disconnect_device(self):
|
||||
"""Unsubscribe from the Redis topic for the device to display"""
|
||||
if self._connected:
|
||||
self._connected = False
|
||||
self.bec_dispatcher.disconnect_slot(self.on_device_readback, self._readback_endpoint)
|
||||
|
||||
def _manual_read(self):
|
||||
if self._device is None or not isinstance(
|
||||
(device := self.dev.get(self._device)), Device | Signal
|
||||
):
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
return
|
||||
signal: Signal = (
|
||||
getattr(device, self.signal, None) if isinstance(device, Device) else device
|
||||
)
|
||||
if not isinstance(signal, Signal): # Avoid getting other attributes of device, e.g. methods
|
||||
signal = None
|
||||
if signal is None:
|
||||
self._units = ""
|
||||
self._value = "__"
|
||||
return
|
||||
self._value = signal.get()
|
||||
self._units = signal.get_device_config().get("egu", "")
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def on_device_readback(self, msg: dict, metadata: dict) -> None:
|
||||
"""
|
||||
Update the display with the new value.
|
||||
"""
|
||||
try:
|
||||
signal_to_read = self._patch_hinted_signal()
|
||||
self._value = msg["signals"][signal_to_read]["value"]
|
||||
self.set_display_value(self._value)
|
||||
except Exception as e:
|
||||
self._display.setText("ERROR!")
|
||||
self._display.setToolTip(
|
||||
f"Error processing incoming reading: {msg}, handled with exception: {''.join(traceback.format_exception(e))}"
|
||||
)
|
||||
|
||||
def _patch_hinted_signal(self):
|
||||
if self.dev[self._device]._info["signals"] == {}:
|
||||
return self._signal
|
||||
signal_info = self.dev[self._device]._info["signals"][self._signal]
|
||||
return (
|
||||
signal_info["obj_name"] if signal_info["kind_str"] == Kind.hinted.name else self._signal
|
||||
)
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self) -> str:
|
||||
"""The device from which to select a signal"""
|
||||
return self._device or "Not set!"
|
||||
|
||||
@device.setter
|
||||
def device(self, value: str) -> None:
|
||||
self.disconnect_device()
|
||||
self._device = value
|
||||
self._config.device = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal(self) -> str:
|
||||
"""The signal to display"""
|
||||
return self._signal or "Not set!"
|
||||
|
||||
@signal.setter
|
||||
def signal(self, value: str) -> None:
|
||||
self.disconnect_device()
|
||||
self._signal = value
|
||||
self._config.default = value
|
||||
self.connect_device()
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_select_button(self) -> bool:
|
||||
"""Show the button to select the signal to display"""
|
||||
return self._show_select_button
|
||||
|
||||
@show_select_button.setter
|
||||
def show_select_button(self, value: bool) -> None:
|
||||
self._show_select_button = value
|
||||
self._select_button.setVisible(value)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_default_units(self) -> bool:
|
||||
"""Show default units obtained from the signal alongside it"""
|
||||
return self._show_default_units
|
||||
|
||||
@show_default_units.setter
|
||||
def show_default_units(self, value: bool) -> None:
|
||||
self._show_default_units = value
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@SafeProperty(str)
|
||||
def custom_label(self) -> str:
|
||||
"""Use a cusom label rather than the signal name"""
|
||||
return self._custom_label
|
||||
|
||||
@custom_label.setter
|
||||
def custom_label(self, value: str) -> None:
|
||||
self._custom_label = value
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(str)
|
||||
def custom_units(self) -> str:
|
||||
"""Use a custom unit string"""
|
||||
return self._custom_units
|
||||
|
||||
@custom_units.setter
|
||||
def custom_units(self, value: str) -> None:
|
||||
self._custom_units = value
|
||||
self.set_display_value(self._value)
|
||||
|
||||
@SafeProperty(int)
|
||||
def decimal_places(self) -> int:
|
||||
"""Format to a given number of decimal_places. Set to 0 to disable."""
|
||||
return self._decimal_places
|
||||
|
||||
@decimal_places.setter
|
||||
def decimal_places(self, value: int) -> None:
|
||||
self._decimal_places = value
|
||||
self._update_label()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_hinted_signals(self) -> bool:
|
||||
"""In the signal selection menu, show hinted signals"""
|
||||
return self._show_hinted_signals
|
||||
|
||||
@show_hinted_signals.setter
|
||||
def show_hinted_signals(self, value: bool) -> None:
|
||||
self._show_hinted_signals = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_config_signals(self) -> bool:
|
||||
"""In the signal selection menu, show config signals"""
|
||||
return self._show_config_signals
|
||||
|
||||
@show_config_signals.setter
|
||||
def show_config_signals(self, value: bool) -> None:
|
||||
self._show_config_signals = value
|
||||
|
||||
@SafeProperty(bool)
|
||||
def show_normal_signals(self) -> bool:
|
||||
"""In the signal selection menu, show normal signals"""
|
||||
return self._show_normal_signals
|
||||
|
||||
@show_normal_signals.setter
|
||||
def show_normal_signals(self, value: bool) -> None:
|
||||
self._show_normal_signals = value
|
||||
|
||||
def _format_value(self, value: str):
|
||||
if self._decimal_places == 0:
|
||||
return value
|
||||
try:
|
||||
return f"{float(value):0.{self._decimal_places}f}"
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
@SafeSlot(str)
|
||||
def set_display_value(self, value: str):
|
||||
"""Set the display to a given value, appending the units if specified"""
|
||||
self._display.setText(f"{self._format_value(value)}{self._units_string}")
|
||||
self._display.setToolTip("")
|
||||
|
||||
@property
|
||||
def _units_string(self):
|
||||
if self.custom_units or self._show_default_units:
|
||||
return f" {self.custom_units or self._default_units or ''}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def _default_units(self) -> str:
|
||||
return self._units
|
||||
|
||||
@property
|
||||
def _default_label(self) -> str:
|
||||
return (
|
||||
str(self._signal) if self._device == self._signal else f"{self._device} {self._signal}"
|
||||
)
|
||||
|
||||
def _update_label(self):
|
||||
self._label.setTitle(
|
||||
self._custom_label if self._custom_label else f"{self._default_label}:"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
w = QWidget()
|
||||
w.setLayout(QVBoxLayout())
|
||||
w.layout().addWidget(
|
||||
SignalLabel(
|
||||
device="samx",
|
||||
signal="readback",
|
||||
custom_label="custom label:",
|
||||
custom_units=" m/s/s",
|
||||
show_select_button=False,
|
||||
)
|
||||
)
|
||||
w.layout().addWidget(SignalLabel(device="samy", signal="readback", show_default_units=True))
|
||||
l = SignalLabel()
|
||||
l.device = "bpm4i"
|
||||
l.signal = "bpm4i"
|
||||
w.layout().addWidget(l)
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['signal_label.py']}
|
||||
@@ -1,43 +1,39 @@
|
||||
# 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.editors.console.console import BECConsole
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='BECConsole' name='bec_console'>
|
||||
<widget class='SignalLabel' name='signal_label'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
|
||||
|
||||
|
||||
class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
class SignalLabelPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
t = BECConsole(parent)
|
||||
t = SignalLabel(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return "BEC Console"
|
||||
return "BEC Utils"
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(BECConsole.ICON_NAME)
|
||||
return designer_material_icon(SignalLabel.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "bec_console"
|
||||
return "signal_label"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
@@ -49,10 +45,10 @@ class BECConsolePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "BECConsole"
|
||||
return "SignalLabel"
|
||||
|
||||
def toolTip(self):
|
||||
return "A terminal-like vt100 widget."
|
||||
return "Display the live value of any signal"
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
BIN
docs/user/widgets/signal_label/designer_screenshot.png
Normal file
BIN
docs/user/widgets/signal_label/designer_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
102
docs/user/widgets/signal_label/signal_label.md
Normal file
102
docs/user/widgets/signal_label/signal_label.md
Normal file
@@ -0,0 +1,102 @@
|
||||
(user.widgets.signal_label)=
|
||||
|
||||
# Signal Label widget
|
||||
|
||||
````{tab} Overview
|
||||
|
||||
The [`SignalLabel`](/api_reference/_autosummary/bec_widgets.cli.client.SignalLabel) displays the value of a signal from a device, with optional customization for labels, units, decimal formatting, and signal selection. It is designed for use in BEC (Beamline Experiment Control) GUIs to monitor values which beamline operators might want to keep an eye on, e.g. sample position, flux, hutch state...
|
||||
|
||||
## Key Features:
|
||||
- Display: Shows the current value of a device signal.
|
||||
- Custom Label/Units: Optionally override the default label and units.
|
||||
- Decimal Formatting: Control the number of decimal places shown.
|
||||
- Signal Selection: (Optional) Button to open a dialog for selecting a device and signal.
|
||||
- Live Updates: Subscribes to device updates and refreshes the display automatically.
|
||||
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - python
|
||||
|
||||
The `SignalLabel` widget can be used inside another widget to build an overall GUI display. For example, to create a display
|
||||
for the sample position like this:
|
||||
|
||||
|
||||
```{figure} ./test_screenshot.png
|
||||
```
|
||||
|
||||
You can simply add three of these signal displays as done here:
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import SignalLabel
|
||||
|
||||
|
||||
class SamplePositionWidget(BECWidget, QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self.setLayout(QVBoxLayout())
|
||||
self.samx_readback = SignalLabel(
|
||||
device="samx",
|
||||
signal="readback",
|
||||
custom_label="Sample X:",
|
||||
custom_units="mm",
|
||||
show_select_button=False,
|
||||
show_default_units=False,
|
||||
)
|
||||
self.samy_readback = SignalLabel(
|
||||
device="samy",
|
||||
signal="readback",
|
||||
custom_label="Sample Y:",
|
||||
custom_units="mm",
|
||||
show_select_button=False,
|
||||
show_default_units=False,
|
||||
)
|
||||
self.samz_readback = SignalLabel(
|
||||
device="samz",
|
||||
signal="readback",
|
||||
custom_label="Sample Z:",
|
||||
custom_units="mm",
|
||||
show_select_button=False,
|
||||
show_default_units=False,
|
||||
)
|
||||
self.layout().addWidget(self.samx_readback)
|
||||
self.layout().addWidget(self.samy_readback)
|
||||
self.layout().addWidget(self.samz_readback)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication()
|
||||
w = SamplePositionWidget()
|
||||
w.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
```
|
||||
|
||||
````
|
||||
|
||||
````{tab} Examples - BEC desginer
|
||||
The various properties can also be set when the SignalLabel widget is added to a UI in BEC designer:
|
||||
|
||||
```{figure} ./designer_screenshot.png
|
||||
```
|
||||
````
|
||||
|
||||
````{tab} API
|
||||
```{eval-rst}
|
||||
.. include:: /api_reference/_autosummary/bec_widgets.cli.client.TextBox.rst
|
||||
```
|
||||
````
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BIN
docs/user/widgets/signal_label/test_screenshot.png
Normal file
BIN
docs/user/widgets/signal_label/test_screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -175,6 +175,14 @@ Various buttons which manage the control of the BEC Queue.
|
||||
Choose individual device from current session.
|
||||
```
|
||||
|
||||
```{grid-item-card} Signal Label
|
||||
:link: user.widgets.signal_label
|
||||
:link-type: ref
|
||||
:img-top: ./signal_label/test_screenshot.png
|
||||
|
||||
Display the live value of a signal.
|
||||
```
|
||||
|
||||
```{grid-item-card} Signal Input Widgets
|
||||
:link: user.widgets.signal_input
|
||||
:link-type: ref
|
||||
@@ -289,5 +297,7 @@ lmfit_dialog/lmfit_dialog.md
|
||||
dap_combo_box/dap_combo_box.md
|
||||
games/games.md
|
||||
log_panel/log_panel.md
|
||||
signal_label/signal_label.md
|
||||
|
||||
|
||||
```
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "2.8.2"
|
||||
version = "2.10.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
@@ -21,7 +21,6 @@ dependencies = [
|
||||
"pydantic~=2.0",
|
||||
"pyqtgraph~=0.13",
|
||||
"PySide6~=6.8.2",
|
||||
"pyte", # needed for vt100 console
|
||||
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
|
||||
"qtpy~=2.4",
|
||||
]
|
||||
|
||||
@@ -5,11 +5,20 @@ import random
|
||||
import pytest
|
||||
|
||||
from bec_widgets.cli.client_utils import BECGuiClient
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def threads_check_fixture(threads_check):
|
||||
"""
|
||||
|
||||
@@ -3,15 +3,6 @@ import time
|
||||
import pytest
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
def test_scan_control_populate_scans_e2e(scan_control):
|
||||
@@ -27,6 +18,7 @@ def test_scan_control_populate_scans_e2e(scan_control):
|
||||
"monitor_scan",
|
||||
"acquire",
|
||||
"line_scan",
|
||||
"custom_testing_scan",
|
||||
]
|
||||
items = [
|
||||
scan_control.comboBox_scan_selection.itemText(i)
|
||||
|
||||
94
tests/end-2-end/test_with_plugins_e2e.py
Normal file
94
tests/end-2-end/test_with_plugins_e2e.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from bec_testing_plugin.scans.metadata_schema.custom_test_scan_schema import CustomScanSchema
|
||||
except ImportError:
|
||||
pytest.skip(reason="Requires plugin repo!", allow_module_level=True)
|
||||
|
||||
from qtpy.QtWidgets import QGridLayout
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def scan_control(qtbot, bec_client_lib): # , mock_dev):
|
||||
widget = ScanControl(client=bec_client_lib)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
["md", "valid"],
|
||||
[
|
||||
({"treatment_description": "soaking", "treatment_temperature_k": 123}, True),
|
||||
({"treatment_description": "soaking", "treatment_temperature_k": "wrong type"}, False),
|
||||
({"treatment_description": "soaking", "wrong key": 123}, False),
|
||||
(
|
||||
{
|
||||
"sample_name": "test sample",
|
||||
"treatment_description": "soaking",
|
||||
"treatment_temperature_k": 123,
|
||||
},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_scan_metadata_for_custom_scan(
|
||||
scan_control: ScanControl, bec_client_lib, qtbot, md: dict, valid: bool
|
||||
):
|
||||
client = bec_client_lib
|
||||
queue = client.queue
|
||||
|
||||
scan_name = "custom_testing_scan"
|
||||
kwargs = {"exp_time": 0.01, "steps": 10, "relative": True, "burst_at_each_point": 1}
|
||||
args = {"device": "samx", "start": -5, "stop": 5}
|
||||
|
||||
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
# Set kwargs in the UI
|
||||
for kwarg_box in scan_control.kwarg_boxes:
|
||||
for widget in kwarg_box.widgets:
|
||||
for key, value in kwargs.items():
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
# Set args in the UI
|
||||
for widget in scan_control.arg_box.widgets:
|
||||
for key, value in args.items():
|
||||
if widget.arg_name == key:
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
assert scan_control._metadata_form._md_schema == CustomScanSchema
|
||||
assert not scan_control.button_run_scan.isEnabled()
|
||||
|
||||
def do_test():
|
||||
# Set the metadata
|
||||
grid: QGridLayout = scan_control._metadata_form._form_grid.layout()
|
||||
for i in range(grid.rowCount()): # type: ignore
|
||||
field_name = grid.itemAtPosition(i, 0).widget().property("_model_field_name")
|
||||
if (value_to_set := md.pop(field_name, None)) is not None:
|
||||
grid.itemAtPosition(i, 1).widget().setValue(value_to_set)
|
||||
# all values should be used
|
||||
assert md == {}
|
||||
assert scan_control.button_run_scan.isEnabled()
|
||||
|
||||
# Run the scan
|
||||
scan_control.button_run_scan.click()
|
||||
time.sleep(2)
|
||||
|
||||
last_scan = queue.scan_storage.storage[-1]
|
||||
assert last_scan.status_message.info["scan_name"] == scan_name
|
||||
assert last_scan.status_message.info["exp_time"] == kwargs["exp_time"]
|
||||
assert last_scan.status_message.info["scan_motors"] == [args["device"]]
|
||||
assert last_scan.status_message.info["num_points"] == kwargs["steps"]
|
||||
|
||||
if valid:
|
||||
do_test()
|
||||
else:
|
||||
with pytest.raises(Exception):
|
||||
do_test()
|
||||
@@ -1,65 +0,0 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from pygments.token import Token
|
||||
from qtpy.QtCore import QEventLoop
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.widgets.editors.console.console import BECConsole
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console_widget(qtbot):
|
||||
apply_theme("light")
|
||||
console = BECConsole()
|
||||
console.set_cmd(sys.executable) # will launch Python interpreter
|
||||
console.set_prompt_tokens((Token.Prompt, ">>>"))
|
||||
qtbot.addWidget(console)
|
||||
console.show()
|
||||
qtbot.waitExposed(console)
|
||||
yield console
|
||||
console.terminate()
|
||||
|
||||
|
||||
def test_console_widget(console_widget, qtbot, tmp_path):
|
||||
def wait_prompt(command_to_execute=None, busy=False):
|
||||
signal_waiter = QEventLoop()
|
||||
|
||||
def exit_loop(idle):
|
||||
if busy and not idle:
|
||||
signal_waiter.quit()
|
||||
elif not busy and idle:
|
||||
signal_waiter.quit()
|
||||
|
||||
console_widget.prompt.connect(exit_loop)
|
||||
if command_to_execute:
|
||||
if callable(command_to_execute):
|
||||
command_to_execute()
|
||||
else:
|
||||
console_widget.execute_command(command_to_execute)
|
||||
signal_waiter.exec_()
|
||||
|
||||
console_widget.start()
|
||||
wait_prompt()
|
||||
|
||||
# use console to write something to a tmp file
|
||||
tmp_filename = str(tmp_path / "console_test.txt")
|
||||
wait_prompt(f"f = open('{tmp_filename}', 'wt'); f.write('HELLO CONSOLE'); f.close()")
|
||||
# check the code has been executed by console, by checking the tmp file contents
|
||||
with open(tmp_filename, "rt") as f:
|
||||
assert f.read() == "HELLO CONSOLE"
|
||||
|
||||
# execute a sleep
|
||||
t0 = time.perf_counter()
|
||||
wait_prompt("import time; time.sleep(1)")
|
||||
assert time.perf_counter() - t0 >= 1
|
||||
|
||||
# test ctrl-c
|
||||
t0 = time.perf_counter()
|
||||
wait_prompt("time.sleep(5)", busy=True)
|
||||
wait_prompt(console_widget.send_ctrl_c)
|
||||
assert (
|
||||
time.perf_counter() - t0 < 1
|
||||
) # in reality it will be almost immediate, but ok we can say less than 1 second compared to 5
|
||||
@@ -236,7 +236,7 @@ def test_update_coord_label_1D(plot_widget_with_crosshair):
|
||||
# Provide a test position
|
||||
pos = (10, 20)
|
||||
crosshair.update_coord_label(pos)
|
||||
expected_text = f"({10:.3g}, {20:.3g})"
|
||||
expected_text = f"({10:.3f}, {20:.3f})"
|
||||
# Verify that the coordinate label shows only the 1D coordinates (no intensity line)
|
||||
assert crosshair.coord_label.toPlainText() == expected_text
|
||||
label_pos = crosshair.coord_label.pos()
|
||||
@@ -260,10 +260,54 @@ def test_update_coord_label_2D(image_widget_with_crosshair):
|
||||
ix = int(np.clip(0.5, 0, known_image.shape[0] - 1)) # 0
|
||||
iy = int(np.clip(1.2, 0, known_image.shape[1] - 1)) # 1
|
||||
intensity = known_image[ix, iy] # Expected: 20
|
||||
expected_text = f"({0.5:.3g}, {1.2:.3g})\nIntensity: {intensity:.3g}"
|
||||
expected_text = f"({0.5:.3f}, {1.2:.3f})\nIntensity: {intensity:.3f}"
|
||||
|
||||
assert crosshair.coord_label.toPlainText() == expected_text
|
||||
label_pos = crosshair.coord_label.pos()
|
||||
assert np.isclose(label_pos.x(), 0.5)
|
||||
assert np.isclose(label_pos.y(), 1.2)
|
||||
assert crosshair.coord_label.isVisible()
|
||||
|
||||
|
||||
def test_crosshair_precision_properties(plot_widget_with_crosshair):
|
||||
"""
|
||||
Ensure Crosshair.precision and Crosshair.min_precision behave correctly
|
||||
and that _current_precision() reflects changes immediately.
|
||||
"""
|
||||
crosshair, plot_item = plot_widget_with_crosshair
|
||||
|
||||
assert crosshair.precision == 3
|
||||
assert crosshair._current_precision() == 3
|
||||
|
||||
crosshair.precision = None
|
||||
plot_item.vb.setXRange(0, 1_000, padding=0)
|
||||
plot_item.vb.setYRange(0, 1_000, padding=0)
|
||||
assert crosshair._current_precision() == crosshair.min_precision == 2 # default floor
|
||||
|
||||
crosshair.min_precision = 5
|
||||
assert crosshair._current_precision() == 5
|
||||
|
||||
crosshair.precision = 1
|
||||
assert crosshair._current_precision() == 1
|
||||
|
||||
|
||||
def test_crosshair_precision_properties_image(image_widget_with_crosshair):
|
||||
"""
|
||||
The same precision/min_precision behaviour must apply for crosshairs attached
|
||||
to ImageItem-based plots.
|
||||
"""
|
||||
crosshair, plot_item = image_widget_with_crosshair
|
||||
|
||||
assert crosshair.precision == 3
|
||||
assert crosshair._current_precision() == 3
|
||||
|
||||
crosshair.precision = None
|
||||
plot_item.vb.setXRange(0, 1_000, padding=0)
|
||||
plot_item.vb.setYRange(0, 1_000, padding=0)
|
||||
assert crosshair._current_precision() == crosshair.min_precision == 2
|
||||
|
||||
crosshair.min_precision = 6
|
||||
assert crosshair._current_precision() == 6
|
||||
|
||||
crosshair.precision = 2
|
||||
assert crosshair._current_precision() == 2
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_device_signal_combo(qtbot, mocked_client):
|
||||
def test_device_signal_base_init(device_signal_base):
|
||||
"""Test if the DeviceSignalInputBase is initialized correctly"""
|
||||
assert device_signal_base._device is None
|
||||
assert device_signal_base._signal_filter == []
|
||||
assert device_signal_base._signal_filter == set()
|
||||
assert device_signal_base._signals == []
|
||||
assert device_signal_base._hinted_signals == []
|
||||
assert device_signal_base._normal_signals == []
|
||||
@@ -76,12 +76,22 @@ def test_device_signal_base_init(device_signal_base):
|
||||
|
||||
def test_device_signal_qproperties(device_signal_base):
|
||||
"""Test if the DeviceSignalInputBase has the correct QProperties"""
|
||||
assert device_signal_base._signal_filter == set()
|
||||
device_signal_base.include_config_signals = False
|
||||
device_signal_base.include_normal_signals = False
|
||||
assert device_signal_base._signal_filter == set()
|
||||
device_signal_base.include_config_signals = True
|
||||
assert device_signal_base._signal_filter == [Kind.config]
|
||||
assert device_signal_base._signal_filter == {Kind.config}
|
||||
device_signal_base.include_normal_signals = True
|
||||
assert device_signal_base._signal_filter == [Kind.config, Kind.normal]
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
|
||||
device_signal_base.include_hinted_signals = True
|
||||
assert device_signal_base._signal_filter == [Kind.config, Kind.normal, Kind.hinted]
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
|
||||
device_signal_base.include_hinted_signals = True
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
|
||||
device_signal_base.include_hinted_signals = True
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted}
|
||||
device_signal_base.include_hinted_signals = False
|
||||
assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
|
||||
|
||||
|
||||
def test_device_signal_set_device(device_signal_base):
|
||||
@@ -123,7 +133,7 @@ def test_signal_combobox(qtbot, device_signal_combobox):
|
||||
assert device_signal_combobox._hinted_signals == ["fake_signal"]
|
||||
|
||||
|
||||
def test_signal_lineeidt(device_signal_line_edit):
|
||||
def test_signal_lineedit(device_signal_line_edit):
|
||||
"""Test the signal_combobox"""
|
||||
|
||||
assert device_signal_line_edit._signals == []
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from collections import deque
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.messages import LogMessage
|
||||
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
|
||||
from bec_lib.redis_connector import StreamMessage
|
||||
from qtpy.QtCore import QDateTime
|
||||
|
||||
from bec_widgets.widgets.utility.logpanel._util import (
|
||||
log_time,
|
||||
@@ -65,7 +66,6 @@ def log_panel(qtbot, mocked_client: MagicMock):
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
yield widget
|
||||
widget.cleanup()
|
||||
|
||||
|
||||
def test_log_panel_init(log_panel: LogPanel):
|
||||
@@ -97,14 +97,13 @@ def test_logpanel_output(qtbot, log_panel: LogPanel):
|
||||
return len(log_panel._log_manager._display_queue) == 0
|
||||
|
||||
next_text = "datetime | error | test log message"
|
||||
msg = LogMessage(
|
||||
metadata={},
|
||||
log_type="error",
|
||||
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
{
|
||||
"data": LogMessage(
|
||||
metadata={},
|
||||
log_type="error",
|
||||
log_msg={"text": next_text, "record": {}, "service_name": "ScanServer"},
|
||||
)
|
||||
}
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
)
|
||||
|
||||
qtbot.waitUntil(display_queue_empty, timeout=5000)
|
||||
@@ -136,3 +135,35 @@ def test_timestamp_filter(log_panel: LogPanel):
|
||||
assert not filter_(TEST_LOG_MESSAGES[0])
|
||||
assert filter_(TEST_LOG_MESSAGES[1])
|
||||
assert not filter_(TEST_LOG_MESSAGES[2])
|
||||
|
||||
|
||||
def test_error_handling_in_callback(log_panel: LogPanel):
|
||||
log_panel._log_manager.new_message = MagicMock()
|
||||
|
||||
with patch("bec_widgets.widgets.utility.logpanel.logpanel.logger") as logger:
|
||||
# generally errors should be logged
|
||||
log_panel._log_manager.new_message.emit = MagicMock(
|
||||
side_effect=ValueError("Something went wrong")
|
||||
)
|
||||
msg = LogMessage(
|
||||
metadata={},
|
||||
log_type="debug",
|
||||
log_msg={
|
||||
"text": "datetime | debug | test log message",
|
||||
"record": {"time": {"timestamp": 123456789.000}},
|
||||
"service_name": "ScanServer",
|
||||
},
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
)
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
# this specific error should be ignored and not relogged
|
||||
log_panel._log_manager.new_message.emit = MagicMock(
|
||||
side_effect=RuntimeError("Internal C++ object (BecLogsQueue) already deleted.")
|
||||
)
|
||||
log_panel._log_manager._process_incoming_log_msg(
|
||||
msg.content, msg.metadata, _override_slot_params={"verify_sender": False}
|
||||
)
|
||||
logger.warning.assert_called_once()
|
||||
|
||||
@@ -349,3 +349,39 @@ def test_enable_fps_monitor_property(qtbot, mocked_client):
|
||||
|
||||
pb.enable_fps_monitor = False
|
||||
assert pb.fps_monitor is None
|
||||
|
||||
|
||||
def test_minimal_crosshair_precision_default(qtbot, mocked_client):
|
||||
"""
|
||||
By default PlotBase should expose a floor of 3 decimals, with no crosshair yet.
|
||||
"""
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
assert pb.minimal_crosshair_precision == 3
|
||||
assert pb.crosshair is None
|
||||
|
||||
|
||||
def test_minimal_crosshair_precision_before_hook(qtbot, mocked_client):
|
||||
"""
|
||||
If the floor is changed before hook_crosshair(), the new crosshair must pick it up.
|
||||
"""
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
pb.minimal_crosshair_precision = 5
|
||||
pb.hook_crosshair()
|
||||
assert pb.crosshair is not None
|
||||
assert pb.crosshair.min_precision == 5
|
||||
|
||||
|
||||
def test_minimal_crosshair_precision_after_hook(qtbot, mocked_client):
|
||||
"""
|
||||
Changing the floor after the crosshair exists should update it immediately
|
||||
and emit the property_changed signal.
|
||||
"""
|
||||
pb = create_widget(qtbot, PlotBase, client=mocked_client)
|
||||
pb.hook_crosshair()
|
||||
assert pb.crosshair is not None
|
||||
|
||||
with qtbot.waitSignal(pb.property_changed, timeout=500) as sig:
|
||||
pb.minimal_crosshair_precision = 1
|
||||
|
||||
assert sig.args == ["minimal_crosshair_precision", 1]
|
||||
assert pb.crosshair.min_precision == 1
|
||||
|
||||
243
tests/unit_tests/test_signal_label.py
Normal file
243
tests/unit_tests/test_signal_label.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from qtpy import QtCore
|
||||
from qtpy.QtWidgets import QDialogButtonBox, QLabel
|
||||
|
||||
from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import (
|
||||
DeviceSignalInputBaseConfig,
|
||||
)
|
||||
from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
SAMX_INFO_DICT = {
|
||||
"signals": {
|
||||
"readback": {
|
||||
"component_name": "readback",
|
||||
"obj_name": "samx",
|
||||
"kind_int": 5,
|
||||
"kind_str": "hinted",
|
||||
"doc": "",
|
||||
"describe": {"source": "SIM:samx", "dtype": "integer", "shape": [], "precision": 3},
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"read_access": True,
|
||||
"write_access": False,
|
||||
"timestamp": 123456.789,
|
||||
"status": None,
|
||||
"severity": None,
|
||||
"precision": None,
|
||||
},
|
||||
}
|
||||
},
|
||||
"setpoint": {
|
||||
"component_name": "setpoint",
|
||||
"obj_name": "samx_setpoint",
|
||||
"kind_int": 1,
|
||||
"kind_str": "normal",
|
||||
"doc": "",
|
||||
"describe": {
|
||||
"source": "SIM:samx_setpoint",
|
||||
"dtype": "integer",
|
||||
"shape": [],
|
||||
"precision": 3,
|
||||
},
|
||||
"metadata": {
|
||||
"connected": True,
|
||||
"read_access": True,
|
||||
"write_access": True,
|
||||
"timestamp": 1747657955.012516,
|
||||
"status": None,
|
||||
"severity": None,
|
||||
"precision": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signal_label(qtbot, mocked_client: MagicMock):
|
||||
with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT):
|
||||
config = DeviceSignalInputBaseConfig(device="samx", default="samx")
|
||||
widget = SignalLabel(
|
||||
config=config, custom_label="Test Label", custom_units="m/s", client=mocked_client
|
||||
)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
widget.show()
|
||||
yield widget
|
||||
|
||||
|
||||
def test_initialization(signal_label: SignalLabel):
|
||||
"""Test the initialization of the SignalLabel widget."""
|
||||
assert signal_label.device == "Not set!"
|
||||
assert signal_label.custom_label == "Test Label"
|
||||
assert signal_label.custom_units == "m/s"
|
||||
assert signal_label.show_select_button is True
|
||||
assert signal_label.show_default_units is False
|
||||
assert signal_label.decimal_places == 3
|
||||
signal_label.set_display_value()
|
||||
assert signal_label._display.text() == ""
|
||||
|
||||
|
||||
def test_initialization_with_device(qtbot, mocked_client: MagicMock):
|
||||
with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT):
|
||||
widget = SignalLabel(device="samx", signal="readback", client=mocked_client)
|
||||
qtbot.addWidget(widget)
|
||||
qtbot.waitExposed(widget)
|
||||
widget.show()
|
||||
assert widget._label.title() == "samx readback:"
|
||||
|
||||
|
||||
def test_set_display_value(signal_label: SignalLabel, qtbot):
|
||||
qtbot.addWidget(signal_label)
|
||||
signal_label.set_display_value("123.456")
|
||||
assert signal_label._display.text() == "123.456 m/s"
|
||||
|
||||
|
||||
def test_show_select_button(signal_label: SignalLabel, qtbot):
|
||||
assert signal_label.show_select_button == True
|
||||
qtbot.waitUntil(lambda: signal_label._select_button.isVisible(), timeout=1000)
|
||||
signal_label.show_select_button = False
|
||||
qtbot.waitUntil(lambda: not signal_label._select_button.isVisible(), timeout=1000)
|
||||
signal_label.show_select_button = True
|
||||
qtbot.waitUntil(lambda: signal_label._select_button.isVisible(), timeout=1000)
|
||||
|
||||
|
||||
def test_show_default_units(signal_label: SignalLabel, qtbot):
|
||||
signal_label.show_default_units = True
|
||||
assert signal_label.show_default_units is True
|
||||
signal_label.show_default_units = False
|
||||
assert signal_label.show_default_units is False
|
||||
|
||||
|
||||
def test_custom_label(signal_label: SignalLabel, qtbot):
|
||||
signal_label.custom_label = "New Label"
|
||||
assert signal_label._label.title() == "New Label"
|
||||
|
||||
|
||||
def test_units_in_display(signal_label: SignalLabel, qtbot):
|
||||
signal_label._value = "1.8"
|
||||
signal_label.custom_units = "Mfurlong μfortnight⁻¹"
|
||||
assert signal_label._display.text() == "1.800 Mfurlong μfortnight⁻¹"
|
||||
|
||||
|
||||
def test_decimal_places(signal_label: SignalLabel, qtbot):
|
||||
signal_label.decimal_places = 2
|
||||
signal_label.set_display_value("123.456")
|
||||
assert signal_label._display.text() == "123.46 m/s"
|
||||
signal_label.decimal_places = 0
|
||||
signal_label.set_display_value("123.456")
|
||||
assert signal_label._display.text() == "123.456 m/s"
|
||||
|
||||
|
||||
def test_choose_signal_dialog_sends_choices(signal_label: SignalLabel, qtbot):
|
||||
signal_label._process_dialog = MagicMock()
|
||||
dialog = signal_label.show_choice_dialog()
|
||||
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
|
||||
dialog._device_field.dev["test device"] = MagicMock()
|
||||
dialog._device_field.setText("test device")
|
||||
dialog._signal_field.addItem("test signal")
|
||||
dialog._signal_field.setCurrentIndex(0)
|
||||
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
|
||||
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
|
||||
signal_label._process_dialog.assert_called_once_with("test device", "test signal")
|
||||
|
||||
|
||||
def test_dialog_handler_updates_devices(signal_label: SignalLabel, qtbot):
|
||||
dialog = signal_label.show_choice_dialog()
|
||||
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
|
||||
dialog._device_field.dev["flux_capacitor"] = MagicMock()
|
||||
dialog._device_field.setText("flux_capacitor")
|
||||
dialog._signal_field.addItem("spin_speed")
|
||||
dialog._signal_field.setCurrentIndex(0)
|
||||
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
|
||||
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
|
||||
assert signal_label._device == "flux_capacitor"
|
||||
assert signal_label._signal == "spin_speed"
|
||||
|
||||
|
||||
def test_choose_signal_dialog_invalid_device(signal_label: SignalLabel, qtbot):
|
||||
signal_label._process_dialog = MagicMock()
|
||||
dialog = signal_label.show_choice_dialog()
|
||||
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
|
||||
dialog._device_field.setText("invalid device")
|
||||
dialog._signal_field.addItem("test signal")
|
||||
dialog._signal_field.setCurrentIndex(0)
|
||||
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton)
|
||||
qtbot.wait(100)
|
||||
qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Cancel), QtCore.Qt.LeftButton)
|
||||
qtbot.waitUntil(lambda: not dialog.isVisible(), timeout=1000)
|
||||
signal_label._process_dialog.assert_not_called()
|
||||
|
||||
|
||||
def test_choice_dialog_with_no_client(qtbot):
|
||||
dialog = ChoiceDialog()
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.button_box.button(QDialogButtonBox.Ok) is None
|
||||
assert dialog.button_box.button(QDialogButtonBox.Cancel) is not None
|
||||
assert dialog.layout().itemAt(0).widget().text() == ChoiceDialog.CONNECTION_ERROR_STR
|
||||
|
||||
|
||||
def test_dialog_has_signals(signal_label: SignalLabel, qtbot):
|
||||
signal_label._process_dialog = MagicMock()
|
||||
dialog = signal_label.show_choice_dialog()
|
||||
qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500)
|
||||
dialog._device_field.dev["test device"] = MagicMock()
|
||||
dialog._device_field.dev["test device"]._info = {
|
||||
"signals": {"signal 1": {"kind_str": "hinted"}, "signal 2": {"kind_str": "normal"}}
|
||||
}
|
||||
|
||||
dialog._device_field.setText("test device")
|
||||
assert dialog._signal_field.count() == 2 # the actual signal and the category label
|
||||
assert dialog._signal_field.currentText() == "signal 1"
|
||||
|
||||
|
||||
def test_set_existing_device_and_signal(signal_label: SignalLabel, qtbot):
|
||||
signal_label.device = "samx"
|
||||
signal_label.signal = "readback"
|
||||
assert signal_label._device == "samx"
|
||||
assert signal_label._config.device == "samx"
|
||||
assert signal_label._signal == "readback"
|
||||
assert signal_label._config.default == "readback"
|
||||
|
||||
|
||||
def test_set_nonexisting_device_and_signal(signal_label: SignalLabel, qtbot):
|
||||
signal_label.custom_units = ""
|
||||
signal_label.device = "samq"
|
||||
signal_label.signal = "readfront"
|
||||
assert signal_label._device == "samq"
|
||||
assert signal_label._config.device == "samq"
|
||||
signal_label._manual_read()
|
||||
signal_label.set_display_value(signal_label._value)
|
||||
assert signal_label._display.text() == "__"
|
||||
assert signal_label._signal == "readfront"
|
||||
assert signal_label._config.default == "readfront"
|
||||
signal_label._manual_read()
|
||||
signal_label.set_display_value(signal_label._value)
|
||||
assert signal_label._display.text() == "__"
|
||||
|
||||
|
||||
def test_handle_readback(signal_label: SignalLabel, qtbot):
|
||||
signal_label.device = "samx"
|
||||
signal_label.signal = "readback"
|
||||
signal_label.custom_units = "μm"
|
||||
signal_label.on_device_readback({"random": {"stuff": "in", "corrupted": "reading"}}, {})
|
||||
assert signal_label._display.text() == "ERROR!"
|
||||
assert "Error processing incoming reading" in signal_label._display.toolTip()
|
||||
signal_label.on_device_readback(
|
||||
{
|
||||
"signals": {
|
||||
"samx": {"value": 0.9927490347496489, "timestamp": 1747662246.3741279},
|
||||
"samx_setpoint": {"value": 1.0, "timestamp": 1747662246.368704},
|
||||
"samx_motor_is_moving": {"value": 0, "timestamp": 1747662246.373092},
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert signal_label._display.text() == "0.993 μm"
|
||||
assert signal_label._display.toolTip() == ""
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
@@ -7,6 +9,15 @@ import numpy as np
|
||||
import pyqtgraph as pg
|
||||
import pytest
|
||||
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QSpinBox,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.plots.plot_base import UIMode
|
||||
from bec_widgets.widgets.plots.waveform.curve import DeviceSignal
|
||||
@@ -533,6 +544,7 @@ def test_on_async_readback_add_update(qtbot, mocked_client):
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.scan_item = create_dummy_scan_item()
|
||||
wf._scan_done = False # simulate a live scan
|
||||
c = wf.plot(arg1="async_device", label="async_device-async_device")
|
||||
wf._async_curves = [c]
|
||||
# Suppose existing data
|
||||
@@ -819,3 +831,227 @@ def test_show_dap_summary_popup(qtbot, mocked_client):
|
||||
wf.dap_summary_dialog.close()
|
||||
assert wf.dap_summary_dialog is None
|
||||
assert fit_action.isChecked() is False
|
||||
|
||||
|
||||
#####################################################
|
||||
# The following tests are for the async dataset guard
|
||||
#####################################################
|
||||
|
||||
|
||||
def test_skip_large_dataset_warning_property(qtbot, mocked_client):
|
||||
"""
|
||||
Verify the getter and setter of skip_large_dataset_warning work correctly.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
# Default should be False
|
||||
assert wf.skip_large_dataset_warning is False
|
||||
|
||||
# Set to True
|
||||
wf.skip_large_dataset_warning = True
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
|
||||
# Toggle back to False
|
||||
wf.skip_large_dataset_warning = False
|
||||
assert wf.skip_large_dataset_warning is False
|
||||
|
||||
|
||||
def test_max_dataset_size_mb_property(qtbot, mocked_client):
|
||||
"""
|
||||
Verify getter, setter, and validation of max_dataset_size_mb.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
# Default from WaveformConfig is 1 MB
|
||||
assert wf.max_dataset_size_mb == 10
|
||||
|
||||
# Set to a valid new value
|
||||
wf.max_dataset_size_mb = 5.5
|
||||
assert wf.max_dataset_size_mb == 5.5
|
||||
# Ensure the config is updated too
|
||||
assert wf.config.max_dataset_size_mb == 5.5
|
||||
|
||||
|
||||
def _dummy_dataset(mem_bytes: int, entry: str = "waveform_waveform"):
|
||||
"""
|
||||
Return an object that mimics the BEC dataset structure:
|
||||
it has exactly one attribute `_info` with the expected layout.
|
||||
"""
|
||||
return SimpleNamespace(_info={entry: {"value": {"mem_size": mem_bytes}}})
|
||||
|
||||
|
||||
def test_dataset_guard_under_limit(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Dataset below the limit should load without triggering the dialog.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # 1 MiB
|
||||
|
||||
# If the dialog is called, we flip this flag – it must stay False.
|
||||
called = {"dlg": False}
|
||||
monkeypatch.setattr(
|
||||
Waveform, "_confirm_large_dataset", lambda self, size_mb: called.__setitem__("dlg", True)
|
||||
)
|
||||
|
||||
dataset = _dummy_dataset(mem_bytes=512_000) # ≈0.49 MiB
|
||||
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is True
|
||||
assert called["dlg"] is False
|
||||
|
||||
|
||||
def test_dataset_guard_over_limit_accept(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Dataset above the limit where user presses *Yes*.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # 1 MiB
|
||||
|
||||
# Pretend the user clicked “Yes”
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", lambda *_: True)
|
||||
|
||||
dataset = _dummy_dataset(mem_bytes=2_000_000) # ≈1.9 MiB
|
||||
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is True
|
||||
|
||||
|
||||
def test_dataset_guard_over_limit_reject(qtbot, mocked_client, monkeypatch):
|
||||
"""
|
||||
Dataset above the limit where user presses *No*.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # 1 MiB
|
||||
|
||||
# Pretend the user clicked “No”
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", lambda *_: False)
|
||||
|
||||
dataset = _dummy_dataset(mem_bytes=2_000_000) # ≈1.9 MiB
|
||||
assert wf._check_dataset_size_and_confirm(dataset, "waveform_waveform") is False
|
||||
|
||||
|
||||
##################################################
|
||||
# Dialog propagation behaviour
|
||||
##################################################
|
||||
|
||||
|
||||
def test_dialog_accept_updates_limit(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Simulate clicking 'Yes' in the dialog *after* changing the spinner value.
|
||||
Verify max_dataset_size_mb is updated and dataset loads.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1 # start small
|
||||
|
||||
def fake_confirm(self, size_mb):
|
||||
# Simulate user typing '5' in the spinbox then pressing Yes
|
||||
self.config.max_dataset_size_mb = 5
|
||||
return True # Yes pressed
|
||||
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", fake_confirm)
|
||||
|
||||
big_dataset = _dummy_dataset(mem_bytes=4_800_000) # ≈4.6 MiB
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
|
||||
# The load should be accepted and the limit must reflect the new value
|
||||
assert accepted is True
|
||||
assert wf.max_dataset_size_mb == 5
|
||||
assert wf.config.max_dataset_size_mb == 5
|
||||
|
||||
|
||||
def test_dialog_cancel_sets_skip(monkeypatch, qtbot, mocked_client):
|
||||
"""
|
||||
Simulate clicking 'No' but ticking 'Don't show again'.
|
||||
Verify skip_large_dataset_warning becomes True and dataset is skipped.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
assert wf.skip_large_dataset_warning is False
|
||||
|
||||
def fake_confirm(self, size_mb):
|
||||
# Mimic ticking the checkbox then pressing No
|
||||
self._skip_large_dataset_warning = True
|
||||
return False # No pressed
|
||||
|
||||
monkeypatch.setattr(Waveform, "_confirm_large_dataset", fake_confirm)
|
||||
|
||||
big_dataset = _dummy_dataset(mem_bytes=11_000_000)
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
|
||||
# Dataset must not load, but future warnings are suppressed
|
||||
assert accepted is False
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
|
||||
|
||||
##################################################
|
||||
# Live dialog interaction (no monkey‑patching)
|
||||
##################################################
|
||||
|
||||
|
||||
def _open_dialog_and_click(handler):
|
||||
"""
|
||||
Utility that schedules *handler* to run as soon as a modal
|
||||
dialog is shown. Returns a function suitable for QTimer.singleShot.
|
||||
"""
|
||||
|
||||
def _cb():
|
||||
# Locate the active modal dialog
|
||||
dlg = QApplication.activeModalWidget()
|
||||
assert isinstance(dlg, QDialog), "No active modal dialog found"
|
||||
handler(dlg)
|
||||
|
||||
return _cb
|
||||
|
||||
|
||||
def test_dialog_accept_real_interaction(qtbot, mocked_client):
|
||||
"""
|
||||
End‑to‑end: user changes the limit spinner to 5 MiB, ticks
|
||||
'don't show again', then presses YES.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1
|
||||
|
||||
# Prepare a large dataset (≈4.6 MiB)
|
||||
big_dataset = _dummy_dataset(mem_bytes=4_800_000)
|
||||
|
||||
def handler(dlg):
|
||||
spin: QDoubleSpinBox = dlg.findChild(QDoubleSpinBox)
|
||||
chk: QCheckBox = dlg.findChild(QCheckBox)
|
||||
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
|
||||
|
||||
# # Interact with widgets
|
||||
spin.setValue(5)
|
||||
chk.setChecked(True)
|
||||
|
||||
yes_btn = btns.button(QDialogButtonBox.Yes)
|
||||
yes_btn.click()
|
||||
|
||||
# Schedule the handler right before invoking the check
|
||||
QTimer.singleShot(0, _open_dialog_and_click(handler))
|
||||
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
assert accepted is True
|
||||
assert wf.max_dataset_size_mb == 5
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
|
||||
|
||||
def test_dialog_reject_real_interaction(qtbot, mocked_client):
|
||||
"""
|
||||
End‑to‑end: user leaves spinner unchanged, ticks 'don't show again',
|
||||
and presses NO.
|
||||
"""
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
wf.max_dataset_size_mb = 1
|
||||
|
||||
big_dataset = _dummy_dataset(mem_bytes=4_800_000)
|
||||
|
||||
def handler(dlg):
|
||||
chk: QCheckBox = dlg.findChild(QCheckBox)
|
||||
btns: QDialogButtonBox = dlg.findChild(QDialogButtonBox)
|
||||
|
||||
chk.setChecked(True)
|
||||
no_btn = btns.button(QDialogButtonBox.No)
|
||||
no_btn.click()
|
||||
|
||||
QTimer.singleShot(0, _open_dialog_and_click(handler))
|
||||
|
||||
accepted = wf._check_dataset_size_and_confirm(big_dataset, "waveform_waveform")
|
||||
assert accepted is False
|
||||
assert wf.skip_large_dataset_warning is True
|
||||
# Limit remains unchanged
|
||||
assert wf.max_dataset_size_mb == 1
|
||||
|
||||
Reference in New Issue
Block a user