1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-09 10:10:55 +02:00

Compare commits

...

9 Commits

Author SHA1 Message Date
semantic-release
b7221d1151 1.14.1
Automatically generated by python-semantic-release
2025-01-10 14:34:09 +00:00
fa9ecaf433 fix: cast spinner widget angle to int when using for arc 2025-01-10 15:22:58 +01:00
semantic-release
c751d25f85 1.14.0
Automatically generated by python-semantic-release
2025-01-09 14:29:40 +00:00
e2c7dc98d2 docs: add docs for games/minesweeper 2025-01-09 15:24:00 +01:00
507d46f88b feat(widget): make Minesweeper into BEC widget 2025-01-09 15:24:00 +01:00
57dc1a3afc feat(widgets): added minesweeper widget 2025-01-09 15:24:00 +01:00
semantic-release
6a78da0e71 1.13.0
Automatically generated by python-semantic-release
2025-01-09 14:18:04 +00:00
fb545eebb3 tests(safeslot): wait for panels to be properly rendered 2025-01-09 14:55:31 +01:00
b4a240e463 tests(e2e): wait for the plotting to finish before checking the data 2025-01-09 14:38:58 +01:00
17 changed files with 6335 additions and 12 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ class Widgets(str, enum.Enum):
DeviceComboBox = "DeviceComboBox"
DeviceLineEdit = "DeviceLineEdit"
LMFitDialog = "LMFitDialog"
Minesweeper = "Minesweeper"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerControlLine = "PositionerControlLine"
@@ -3181,6 +3182,9 @@ class LMFitDialog(RPCBase):
"""
class Minesweeper(RPCBase): ...
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):

View File

@@ -224,3 +224,11 @@ DEVICES = [
Positioner("test", limits=[-10, 10], read_value=2.0),
Device("test_device"),
]
def check_remote_data_size(widget, plot_name, num_elements):
"""
Check if the remote data has the correct number of elements.
Used in the qtbot.waitUntil function.
"""
return len(widget.get_all_data()[plot_name]["x"]) == num_elements

View File

@@ -0,0 +1,3 @@
from bec_widgets.widgets.games.minesweeper import Minesweeper
__ALL__ = ["Minesweeper"]

View File

@@ -0,0 +1,413 @@
import enum
import random
import time
from bec_qthemes import material_icon
from qtpy.QtCore import QSize, Qt, QTimer, Signal, Slot
from qtpy.QtGui import QBrush, QColor, QPainter, QPen
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
NUM_COLORS = {
1: QColor("#f44336"),
2: QColor("#9C27B0"),
3: QColor("#3F51B5"),
4: QColor("#03A9F4"),
5: QColor("#00BCD4"),
6: QColor("#4CAF50"),
7: QColor("#E91E63"),
8: QColor("#FF9800"),
}
LEVELS: dict[str, tuple[int, int]] = {"1": (8, 10), "2": (16, 40), "3": (24, 99)}
class GameStatus(enum.Enum):
READY = 0
PLAYING = 1
FAILED = 2
SUCCESS = 3
class Pos(QWidget):
expandable = Signal(int, int)
clicked = Signal()
ohno = Signal()
def __init__(self, x, y, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFixedSize(QSize(20, 20))
self.x = x
self.y = y
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
def reset(self):
"""Restore the tile to its original state before mine status is assigned"""
self.is_start = False
self.is_mine = False
self.adjacent_n = 0
self.is_revealed = False
self.is_flagged = False
self.update()
def paintEvent(self, event):
p = QPainter(self)
r = event.rect()
if self.is_revealed:
color = self.palette().base().color()
outer, inner = color, color
else:
outer, inner = (self.palette().highlightedText().color(), self.palette().text().color())
p.fillRect(r, QBrush(inner))
pen = QPen(outer)
pen.setWidth(1)
p.setPen(pen)
p.drawRect(r)
if self.is_revealed:
if self.is_mine:
p.drawPixmap(r, material_icon("experiment", convert_to_pixmap=True, filled=True))
elif self.adjacent_n > 0:
pen = QPen(NUM_COLORS[self.adjacent_n])
p.setPen(pen)
f = p.font()
f.setBold(True)
p.setFont(f)
p.drawText(r, Qt.AlignHCenter | Qt.AlignVCenter, str(self.adjacent_n))
elif self.is_flagged:
p.drawPixmap(
r,
material_icon(
"flag",
size=(50, 50),
convert_to_pixmap=True,
filled=True,
color=self.palette().base().color(),
),
)
p.end()
def flag(self):
self.is_flagged = not self.is_flagged
self.update()
self.clicked.emit()
def reveal(self):
self.is_revealed = True
self.update()
def click(self):
if not self.is_revealed:
self.reveal()
if self.adjacent_n == 0:
self.expandable.emit(self.x, self.y)
self.clicked.emit()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.RightButton and not self.is_revealed:
self.flag()
return
if event.button() == Qt.MouseButton.LeftButton:
self.click()
if self.is_mine:
self.ohno.emit()
class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
self._ui_initialised = False
self._timer_start_num_seconds = 0
self._set_level_params(LEVELS["1"])
self._init_ui()
self._init_map()
self.update_status(GameStatus.READY)
self.reset_map()
self.update_status(GameStatus.READY)
def _init_ui(self):
if self._ui_initialised:
return
self._ui_initialised = True
status_hb = QHBoxLayout()
self.mines = QLabel()
self.mines.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
f = self.mines.font()
f.setPointSize(24)
self.mines.setFont(f)
self.reset_button = QPushButton()
self.reset_button.setFixedSize(QSize(32, 32))
self.reset_button.setIconSize(QSize(32, 32))
self.reset_button.setFlat(True)
self.reset_button.pressed.connect(self.reset_button_pressed)
self.clock = QLabel()
self.clock.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
self.clock.setFont(f)
self._timer = QTimer()
self._timer.timeout.connect(self.update_timer)
self._timer.start(1000) # 1 second timer
self.mines.setText(f"{self.num_mines:03d}")
self.clock.setText("000")
status_hb.addWidget(self.mines)
status_hb.addWidget(self.reset_button)
status_hb.addWidget(self.clock)
level_hb = QHBoxLayout()
self.level_selector = QComboBox()
self.level_selector.addItems(list(LEVELS.keys()))
level_hb.addWidget(QLabel("Level: "))
level_hb.addWidget(self.level_selector)
self.level_selector.currentTextChanged.connect(self.change_level)
vb = QVBoxLayout()
vb.addLayout(level_hb)
vb.addLayout(status_hb)
self.grid = QGridLayout()
self.grid.setSpacing(5)
vb.addLayout(self.grid)
self.setLayout(vb)
def _init_map(self):
"""Redraw the grid of mines"""
# Remove any previous grid items and reset the grid
for i in reversed(range(self.grid.count())):
w: Pos = self.grid.itemAt(i).widget()
w.clicked.disconnect(self.on_click)
w.expandable.disconnect(self.expand_reveal)
w.ohno.disconnect(self.game_over)
w.setParent(None)
w.deleteLater()
# Add positions to the map
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = Pos(x, y)
self.grid.addWidget(w, y, x)
# Connect signal to handle expansion.
w.clicked.connect(self.on_click)
w.expandable.connect(self.expand_reveal)
w.ohno.connect(self.game_over)
def reset_map(self):
"""
Reset the map and add new mines.
"""
# Clear all mine positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reset()
# Add mines to the positions
positions = []
while len(positions) < self.num_mines:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_mine = True
positions.append((x, y))
def get_adjacency_n(x, y):
positions = self.get_surrounding(x, y)
num_mines = sum(1 if w.is_mine else 0 for w in positions)
return num_mines
# Add adjacencies to the positions
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.adjacent_n = get_adjacency_n(x, y)
# Place starting marker
while True:
x, y = (random.randint(0, self.b_size - 1), random.randint(0, self.b_size - 1))
w = self.grid.itemAtPosition(y, x).widget()
# We don't want to start on a mine.
if (x, y) not in positions:
w = self.grid.itemAtPosition(y, x).widget()
w.is_start = True
# Reveal all positions around this, if they are not mines either.
for w in self.get_surrounding(x, y):
if not w.is_mine:
w.click()
break
def get_surrounding(self, x, y):
positions = []
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
positions.append(self.grid.itemAtPosition(yi, xi).widget())
return positions
def get_num_hidden(self) -> int:
"""
Get the number of hidden positions.
"""
return sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if not self.grid.itemAtPosition(y, x).widget().is_revealed
)
def get_num_remaining_flags(self) -> int:
"""
Get the number of remaining flags.
"""
return self.num_mines - sum(
1
for x in range(0, self.b_size)
for y in range(0, self.b_size)
if self.grid.itemAtPosition(y, x).widget().is_flagged
)
def reset_button_pressed(self):
match self.status:
case GameStatus.PLAYING:
self.game_over()
case GameStatus.FAILED | GameStatus.SUCCESS:
self.reset_map()
def reveal_map(self):
for x in range(0, self.b_size):
for y in range(0, self.b_size):
w = self.grid.itemAtPosition(y, x).widget()
w.reveal()
@Slot(str)
def change_level(self, level: str):
self._set_level_params(LEVELS[level])
self._init_map()
self.reset_map()
@Slot(int, int)
def expand_reveal(self, x, y):
"""
Expand the reveal to the surrounding
Args:
x (int): The x position.
y (int): The y position.
"""
for xi in range(max(0, x - 1), min(x + 2, self.b_size)):
for yi in range(max(0, y - 1), min(y + 2, self.b_size)):
w = self.grid.itemAtPosition(yi, xi).widget()
if not w.is_mine:
w.click()
@Slot()
def on_click(self):
"""
Handle the click event. If the game is not started, start the game.
"""
self.update_available_flags()
if self.status != GameStatus.PLAYING:
# First click.
self.update_status(GameStatus.PLAYING)
# Start timer.
self._timer_start_num_seconds = int(time.time())
return
self.check_win()
def update_available_flags(self):
"""
Update the number of available flags.
"""
self.mines.setText(f"{self.get_num_remaining_flags():03d}")
def check_win(self):
"""
Check if the game is won.
"""
if self.get_num_hidden() == self.num_mines:
self.update_status(GameStatus.SUCCESS)
def update_status(self, status: GameStatus):
"""
Update the status of the game.
Args:
status (GameStatus): The status of the game.
"""
self.status = status
match status:
case GameStatus.READY:
icon = material_icon(icon_name="add", convert_to_pixmap=False)
case GameStatus.PLAYING:
icon = material_icon(icon_name="smart_toy", convert_to_pixmap=False)
case GameStatus.FAILED:
icon = material_icon(icon_name="error", convert_to_pixmap=False)
case GameStatus.SUCCESS:
icon = material_icon(icon_name="celebration", convert_to_pixmap=False)
self.reset_button.setIcon(icon)
def update_timer(self):
"""
Update the timer.
"""
if self.status == GameStatus.PLAYING:
num_seconds = int(time.time()) - self._timer_start_num_seconds
self.clock.setText(f"{num_seconds:03d}")
def game_over(self):
"""Cause the game to end early"""
self.reveal_map()
self.update_status(GameStatus.FAILED)
def _set_level_params(self, level: tuple[int, int]):
self.b_size, self.num_mines = level
if __name__ == "__main__":
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("light")
widget = Minesweeper()
widget.show()
app.exec_()

View File

@@ -0,0 +1 @@
{'files': ['minesweeper.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.games.minesweeper import Minesweeper
DOM_XML = """
<ui language='c++'>
<widget class='Minesweeper' name='minesweeper'>
</widget>
</ui>
"""
class MinesweeperPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Minesweeper(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Games"
def icon(self):
return designer_material_icon(Minesweeper.ICON_NAME)
def includeFile(self):
return "minesweeper"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "Minesweeper"
def toolTip(self):
return "Minesweeper"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.games.minesweeper_plugin import MinesweeperPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(MinesweeperPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -75,7 +75,7 @@ class SpinnerWidget(QWidget):
proportion = 1 / 4
angle_span = int(proportion * 360 * 16)
angle_span += angle_span * ease_in_out_sine(self.time / self.duration)
painter.drawArc(adjusted_rect, self.angle * 16, int(angle_span))
painter.drawArc(adjusted_rect, int(self.angle * 16), int(angle_span))
painter.end()
def closeEvent(self, event):

View File

@@ -0,0 +1,11 @@
(user.widgets.games)=
# Game widgets
To provide some entertainment during long nights at the beamline, there are game widgets available. Well, only one, so far.
## Minesweeper
![Minesweeper](./minesweeper.png)
The classic game Minesweeper. You may select from three different levels. The game can be ended or reset by clicking on the icon in the top-centre (the robot in the screenshot).

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -270,5 +270,6 @@ signal_input/signal_input.md
position_indicator/position_indicator.md
lmfit_dialog/lmfit_dialog.md
dap_combo_box/dap_combo_box.md
games/games.md
```

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "1.12.0"
version = "1.14.1"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

View File

@@ -4,7 +4,8 @@ import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECDockArea, BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
from bec_widgets.utils import Colors
# pylint: disable=unused-argument
@@ -12,7 +13,7 @@ from bec_widgets.utils import Colors
# pylint: disable=too-many-locals
def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
# BEC client shortcuts
dock = connected_client_dock
client = bec_client_lib
@@ -88,14 +89,17 @@ def test_rpc_add_dock_with_figure_e2e(bec_client_lib, connected_client_dock):
# Try to make a scan
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# wait for scan to finish
while not status.status == "COMPLETED":
time.sleep(0.2)
status.wait()
# plot
item = queue.scan_storage.storage[-1]
plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = len(plt_last_scan_data["samx"]["samx"].val)
plot_name = "bpm4i-bpm4i"
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
plt_data = plt.get_all_data()
assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
@@ -255,11 +259,17 @@ def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot
# get data from curves
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
plt_data = widgets[0].get_all_data()
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = len(last_scan_data["samx"]["samx"].val)
plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
@@ -277,12 +287,18 @@ def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot
plt = auto_updates.get_default_figure()
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
plt_data = widgets[0].get_all_data()
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
plot_name = f"Scan {status.scan.scan_number} - bpm4i"
num_elements_bec = len(last_scan_data["samx"]["samx"].val)
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
@@ -355,6 +371,7 @@ def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_ob
gui = connected_client_gui_obj
gui.main.add_dock("test")
qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
with pytest.raises(ValueError):
gui.main.add_dock("test")
# time.sleep(0.1)

View File

@@ -1,10 +1,10 @@
import time
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
def test_rpc_waveform1d_custom_curve(connected_client_figure):
@@ -78,7 +78,7 @@ def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
}
def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
# add 3 different curves to track
@@ -97,6 +97,11 @@ def test_rpc_waveform_scan(connected_client_figure, bec_client_lib):
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = len(last_scan_data["samx"]["samx"].val)
for plot_name in ["bpm4i-bpm4i", "bpm3a-bpm3a", "bpm4d-bpm4d"]:
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
# get data from curves
plt_data = plt.get_all_data()

View File

@@ -0,0 +1,50 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import pytest
from qtpy.QtCore import Qt
from bec_widgets.widgets.games import Minesweeper
from bec_widgets.widgets.games.minesweeper import LEVELS, GameStatus, Pos
@pytest.fixture
def minesweeper(qtbot):
widget = Minesweeper()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
def test_minesweeper_init(minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
def test_changing_level_updates_size_and_removes_old_grid_items(minesweeper: Minesweeper):
assert minesweeper.b_size == LEVELS["1"][0]
grid_items = [minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())]
for w in grid_items:
assert w.parent() is not None
minesweeper.change_level("2")
assert minesweeper.b_size == LEVELS["2"][0]
for w in grid_items:
assert w.parent() is None
def test_game_state_changes_to_failed_on_loss(qtbot, minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
grid_items: list[Pos] = [
minesweeper.grid.itemAt(i).widget() for i in range(minesweeper.grid.count())
]
mine = [p for p in grid_items if p.is_mine][0]
with qtbot.waitSignal(mine.ohno, timeout=1000):
qtbot.mouseRelease(mine, Qt.MouseButton.LeftButton)
assert minesweeper.status == GameStatus.FAILED
def test_game_resets_on_reset_click(minesweeper: Minesweeper):
assert minesweeper.status == GameStatus.READY
minesweeper.grid.itemAt(1).widget().ohno.emit()
assert minesweeper.status == GameStatus.FAILED
minesweeper.reset_button_pressed()
assert minesweeper.status == GameStatus.PLAYING

View File

@@ -19,6 +19,12 @@ def test_spinner_widget_paint_event(spinner_widget, qtbot):
spinner_widget.paintEvent(None)
def test_spinnner_with_float_angle(spinner_widget, qtbot):
spinner_widget.start()
spinner_widget.angle = 0.123453453453453
spinner_widget.paintEvent(None)
def test_spinner_widget_rendered(spinner_widget, qtbot, tmpdir):
spinner_widget.update()
qtbot.wait(200)