diff --git a/bec_widgets/examples/scan_progress_compare.py b/bec_widgets/examples/scan_progress_compare.py
new file mode 100644
index 00000000..163e20fa
--- /dev/null
+++ b/bec_widgets/examples/scan_progress_compare.py
@@ -0,0 +1,113 @@
+"""
+Side-by-side comparison of the scan progress bar: today's widget, the QML port and the QWidget
+version with the same UX. A simulated scan runs through every state; no BEC server is needed.
+
+Run with: python -m bec_widgets.examples.scan_progress_compare [--light]
+"""
+
+import sys
+import time
+from unittest import mock
+
+from qtpy.QtCore import QTimer
+from qtpy.QtWidgets import QApplication, QGridLayout, QLabel, QPushButton, QWidget
+
+from bec_widgets.utils.colors import apply_theme
+from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar
+from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar_modern import (
+ ScanProgressBarModern,
+)
+from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar_qml import ScanProgressBarQml
+
+POINTS = 60
+
+# (status sent with the progress messages, points to advance, final scan status or None)
+SCRIPT = [("open", 0, None)] * 3 + [("open", 1, None)] * 25 + [("paused", 0, None)] * 6
+SCRIPT += [("open", 1, None)] * 20 + [("open", 0, "halted")] * 6 + [("open", 1, None)] * POINTS
+
+
+class Demo(QWidget):
+ """Drives all bars with the same simulated scan."""
+
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle("Scan progress: today vs QML vs QWidget")
+ client = mock.MagicMock()
+ grid = QGridLayout(self)
+ grid.setHorizontalSpacing(18)
+ self.bars = []
+ for col, (name, cls) in enumerate(
+ [
+ ("Today", ScanProgressBar),
+ ("QML", ScanProgressBarQml),
+ ("QWidget", ScanProgressBarModern),
+ ]
+ ):
+ grid.addWidget(QLabel(f"{name}"), 0, col)
+ for row, compact in enumerate((False, True)):
+ bar = cls(client=client, one_line_design=compact, rpc_exposed=False)
+ bar.setMinimumWidth(320)
+ grid.addWidget(bar, row + 1, col)
+ self.bars.append(bar)
+ restart = QPushButton("Restart scan")
+ restart.clicked.connect(self.start)
+ grid.addWidget(restart, 3, 0)
+ self.timer = QTimer(self)
+ self.timer.timeout.connect(self.step)
+ self.scan = 0
+ self.start()
+
+ def start(self):
+ self.scan += 1
+ self.value = 0
+ self.index = 0
+ self.scan_id = f"scan-{self.scan}"
+ for bar in self.bars:
+ model = getattr(bar, "model", None)
+ if model is not None:
+ model._on_scan_status(
+ {
+ "scan_id": self.scan_id,
+ "status": "open",
+ "scan_name": "line_scan",
+ "scan_number": self.scan,
+ },
+ {},
+ )
+ self.timer.start(250)
+
+ def step(self):
+ if self.index >= len(SCRIPT):
+ self.timer.stop()
+ return
+ status, advance, final = SCRIPT[self.index]
+ self.index += 1
+ self.value = min(POINTS, self.value + advance)
+ max_value = 0 if self.index <= 3 else POINTS
+ done = self.value >= POINTS
+ for bar in self.bars:
+ if final and getattr(bar, "model", None) is not None:
+ bar.model._on_scan_status({"scan_id": self.scan_id, "status": final}, {})
+ continue
+ bar.progress_tracker.process_progress_message(
+ {"value": self.value, "max_value": max_value, "done": done},
+ {
+ "scan_id": self.scan_id,
+ "RID": self.scan_id,
+ "status": final or status,
+ "scan_number": self.scan,
+ },
+ )
+ if final:
+ self.timer.stop()
+ QTimer.singleShot(2000, self.start)
+ elif done:
+ self.timer.stop()
+
+
+if __name__ == "__main__": # pragma: no cover
+ app = QApplication(sys.argv)
+ apply_theme("light" if "--light" in sys.argv else "dark")
+ demo = Demo()
+ demo.show()
+ sys.exit(app.exec())
diff --git a/bec_widgets/widgets/progress/scan_progressbar/qml/ScanProgress.qml b/bec_widgets/widgets/progress/scan_progressbar/qml/ScanProgress.qml
new file mode 100644
index 00000000..63e47d30
--- /dev/null
+++ b/bec_widgets/widgets/progress/scan_progressbar/qml/ScanProgress.qml
@@ -0,0 +1,196 @@
+import QtQuick
+import QtQuick.Layouts
+
+// Scan progress view. Data comes from `progressModel` (ScanProgressModel), colours from `theme`.
+Item {
+ id: root
+
+ property bool compact: false
+ property bool showSource: true
+ property bool showElapsed: true
+ property bool showRemaining: true
+
+ readonly property color fg: theme.c["FG"] || "#e0e0e0"
+ readonly property color muted: theme.c["DISABLED_FG"] || "#888888"
+ readonly property color track: theme.dark ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.07)
+ readonly property color stateColor: theme.c[progressModel.stateColorKey] || "#0a60ff"
+ // chip text needs more contrast than the bar fill on light backgrounds
+ readonly property color stateInk: theme.dark ? stateColor : Qt.darker(stateColor, 1.5)
+
+ implicitHeight: compact ? 22 : 58
+ implicitWidth: compact ? 220 : 320
+
+
+ // ---------- progress track, shared by both layouts ----------
+ component Bar: Rectangle {
+ id: bar
+ radius: height / 2
+ color: root.track
+ clip: true
+
+ Rectangle {
+ id: fill
+ visible: !progressModel.indeterminate
+ height: parent.height
+ radius: parent.radius
+ width: Math.max(progressModel.fraction > 0 ? height : 0, parent.width * progressModel.fraction)
+ color: root.stateColor
+ Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
+ Behavior on color { ColorAnimation { duration: 200 } }
+
+ // soft sheen that travels along the fill while the scan is running
+ Rectangle {
+ visible: progressModel.state === "running" && fill.width > 24
+ width: 40; height: parent.height; radius: parent.radius
+ gradient: Gradient {
+ orientation: Gradient.Horizontal
+ GradientStop { position: 0.0; color: "transparent" }
+ GradientStop { position: 0.5; color: Qt.rgba(1, 1, 1, 0.28) }
+ GradientStop { position: 1.0; color: "transparent" }
+ }
+ NumberAnimation on x {
+ from: -40; to: fill.width; duration: 1600; loops: Animation.Infinite
+ running: progressModel.state === "running"
+ }
+ }
+ }
+
+ // indeterminate: a segment sliding back and forth until the point count is known
+ Rectangle {
+ visible: progressModel.indeterminate
+ width: parent.width * 0.3; height: parent.height; radius: parent.radius
+ color: root.stateColor
+ SequentialAnimation on x {
+ running: progressModel.indeterminate; loops: Animation.Infinite
+ NumberAnimation { from: 0; to: bar.width * 0.7; duration: 900; easing.type: Easing.InOutQuad }
+ NumberAnimation { from: bar.width * 0.7; to: 0; duration: 900; easing.type: Easing.InOutQuad }
+ }
+ }
+ }
+
+ // ---------- compact: one line for status bars ----------
+ RowLayout {
+ visible: root.compact
+ anchors.fill: parent
+ anchors.leftMargin: 4; anchors.rightMargin: 4
+ spacing: 6
+
+ Rectangle {
+ Layout.alignment: Qt.AlignVCenter
+ width: 8; height: 8; radius: 4
+ color: root.stateColor
+ }
+ Text {
+ visible: root.showSource && progressModel.title !== ""
+ text: progressModel.title
+ color: root.fg
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ Layout.maximumWidth: 120
+ }
+ Bar { Layout.fillWidth: true; Layout.preferredHeight: 6; Layout.alignment: Qt.AlignVCenter }
+ Text {
+ text: progressModel.percentText
+ color: root.fg
+ font.pixelSize: 11
+ font.features: { "tnum": 1 }
+ }
+ Text {
+ visible: root.showRemaining && text !== ""
+ text: progressModel.timeText
+ color: root.muted
+ font.pixelSize: 11
+ }
+ }
+
+ // ---------- full layout ----------
+ ColumnLayout {
+ visible: !root.compact
+ anchors.fill: parent
+ anchors.margins: 4
+ spacing: 4
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ // state chip
+ Rectangle {
+ id: chip
+ radius: height / 2
+ implicitHeight: 20
+ implicitWidth: chipRow.implicitWidth + 14
+ color: Qt.rgba(root.stateColor.r, root.stateColor.g, root.stateColor.b, 0.16)
+ Behavior on color { ColorAnimation { duration: 200 } }
+ Row {
+ id: chipRow
+ anchors.centerIn: parent
+ spacing: 4
+ Image {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 14; height: 14
+ sourceSize: Qt.size(28, 28)
+ source: "image://material/" + progressModel.stateIcon + "?filled=1&color=" + encodeURIComponent(root.stateInk)
+ }
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: progressModel.stateLabel
+ color: root.stateInk
+ font.pixelSize: 11
+ font.weight: Font.DemiBold
+ }
+ }
+ }
+ Text {
+ visible: root.showSource
+ Layout.fillWidth: true
+ text: progressModel.title
+ color: progressModel.state === "idle" ? root.muted : root.fg
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+ Item { visible: !root.showSource; Layout.fillWidth: true }
+ Text {
+ text: progressModel.percentText
+ color: root.fg
+ font.pixelSize: 15
+ font.weight: Font.DemiBold
+ font.features: { "tnum": 1 }
+ }
+ }
+
+ Bar { Layout.fillWidth: true; Layout.preferredHeight: 8 }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 6
+ Text {
+ text: progressModel.countText !== "" ? progressModel.countText + " points" : ""
+ color: root.muted
+ font.pixelSize: 11
+ font.features: { "tnum": 1 }
+ }
+ Item { Layout.fillWidth: true }
+ Text {
+ visible: root.showElapsed && progressModel.active && progressModel.elapsedText !== ""
+ text: progressModel.elapsedText + " elapsed"
+ color: root.muted
+ font.pixelSize: 11
+ font.features: { "tnum": 1 }
+ }
+ Text {
+ visible: root.showElapsed && root.showRemaining && progressModel.active && progressModel.elapsedText !== ""
+ text: "·"
+ color: root.muted
+ font.pixelSize: 11
+ }
+ Text {
+ visible: root.showRemaining && text !== ""
+ text: progressModel.timeText
+ color: progressModel.state === "running" ? root.fg : root.muted
+ font.pixelSize: 11
+ font.features: { "tnum": 1 }
+ }
+ }
+ }
+}
diff --git a/bec_widgets/widgets/progress/scan_progressbar/qml_support.py b/bec_widgets/widgets/progress/scan_progressbar/qml_support.py
new file mode 100644
index 00000000..b20e3cee
--- /dev/null
+++ b/bec_widgets/widgets/progress/scan_progressbar/qml_support.py
@@ -0,0 +1,103 @@
+"""Small helpers to host a QML view inside a QWidget: theme bridge and Material icon provider."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from urllib.parse import unquote
+
+from bec_qthemes import material_icon
+from qtpy.QtCore import Property, QObject, QSize, QUrl, Signal
+from qtpy.QtGui import QColor, QPixmap
+from qtpy.QtQuick import QQuickImageProvider
+from qtpy.QtQuickWidgets import QQuickWidget
+from qtpy.QtWidgets import QApplication, QWidget
+
+from bec_widgets.utils.colors import get_theme_name
+from bec_widgets.widgets.progress.scan_progressbar.scan_progress_model import current_theme_colors
+
+
+class QmlTheme(QObject):
+ """Exposes the current bec_qthemes palette to QML as ``theme.c.`` hex strings."""
+
+ changed = Signal()
+
+ def __init__(self, parent: QObject | None = None):
+ super().__init__(parent=parent)
+ self._colors: dict[str, str] = {}
+ self._dark = True
+ self.refresh()
+ app = QApplication.instance()
+ if hasattr(app, "theme"):
+ app.theme.theme_changed.connect(self.refresh)
+
+ def refresh(self, *_args):
+ """Re-read the palette from the application theme."""
+ self._dark = get_theme_name() == "dark"
+ self._colors = {k: QColor(v).name() for k, v in current_theme_colors().items()}
+ self.changed.emit()
+
+ @Property("QVariantMap", notify=changed)
+ def c(self) -> dict:
+ return self._colors
+
+ @Property(bool, notify=changed)
+ def dark(self) -> bool:
+ return self._dark
+
+
+class MaterialIconProvider(QQuickImageProvider):
+ """Serves ``image://material/?color=&filled=1`` from bec_qthemes."""
+
+ def __init__(self):
+ super().__init__(QQuickImageProvider.ImageType.Pixmap)
+
+ def requestPixmap(self, icon_id: str, size, requested_size: QSize) -> QPixmap:
+ name, _, query = icon_id.partition("?")
+ if not name or name == "undefined":
+ return QPixmap()
+ params = {k: unquote(v) for k, v in (p.split("=", 1) for p in query.split("&") if "=" in p)}
+ edge = max(requested_size.width(), requested_size.height(), 0) or 24
+ pixmap = material_icon(
+ name,
+ size=(edge, edge),
+ color=params.get("color", "#888888"),
+ filled=params.get("filled") == "1",
+ )
+ if size is not None:
+ size.setWidth(pixmap.width())
+ size.setHeight(pixmap.height())
+ return pixmap
+
+
+def create_quick_widget(
+ parent: QWidget, qml_file: Path, context: dict[str, QObject]
+) -> QQuickWidget:
+ """
+ Create a QQuickWidget that loads ``qml_file`` with the given context properties.
+
+ The theme bridge is always available as ``theme`` and icons under ``image://material/``.
+
+ Args:
+ parent(QWidget): Parent widget.
+ qml_file(Path): QML file to load.
+ context(dict[str, QObject]): Extra context properties.
+
+ Returns:
+ QQuickWidget: The configured widget.
+ """
+ view = QQuickWidget(parent)
+ view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
+ view.setClearColor(QColor(0, 0, 0, 0))
+ engine = view.engine()
+ # keep Python references, otherwise the wrappers are collected while QML still uses them
+ view._icon_provider = MaterialIconProvider()
+ engine.addImageProvider("material", view._icon_provider)
+ theme = QmlTheme(view)
+ view._qml_theme = theme
+ view.rootContext().setContextProperty("theme", theme)
+ for name, obj in context.items():
+ view.rootContext().setContextProperty(name, obj)
+ view.setSource(QUrl.fromLocalFile(str(qml_file)))
+ for error in view.errors():
+ raise RuntimeError(f"QML error in {qml_file.name}: {error.toString()}")
+ return view
diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progress_model.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progress_model.py
new file mode 100644
index 00000000..4a19c8f2
--- /dev/null
+++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progress_model.py
@@ -0,0 +1,316 @@
+"""Presentation model shared by the QML and QWidget versions of the modern scan progress bar."""
+
+from __future__ import annotations
+
+from bec_lib.endpoints import MessageEndpoints
+from bec_qthemes._theme import ACCENT_COLORS
+from qtpy.QtCore import Property, QObject, QTimer, Signal
+from qtpy.QtGui import QColor, QPalette
+from qtpy.QtWidgets import QApplication
+
+from bec_widgets.utils.error_popups import SafeSlot
+from bec_widgets.widgets.progress.progress_backend import BECProgressTracker, ProgressSnapshot
+
+# BEC scan status -> display state
+STATUS_TO_STATE = {
+ "open": "running",
+ "paused": "paused",
+ "aborted": "aborted",
+ "halted": "halted",
+ "closed": "done",
+ "user_completed": "done",
+}
+
+STATE_LABELS = {
+ "idle": "Idle",
+ "running": "Running",
+ "paused": "Paused",
+ "aborted": "Aborted",
+ "halted": "Halted",
+ "done": "Done",
+}
+
+# Theme color key used for each state (bec_qthemes palette keys)
+STATE_COLOR_KEYS = {
+ "idle": "DISABLED_FG",
+ "running": "ACCENT_DEFAULT",
+ "paused": "ACCENT_WARNING",
+ "aborted": "ACCENT_HIGHLIGHT",
+ "halted": "ACCENT_EMERGENCY",
+ "done": "ACCENT_SUCCESS",
+}
+
+STATE_ICONS = {
+ "idle": "radio_button_unchecked",
+ "running": "play_circle",
+ "paused": "pause_circle",
+ "aborted": "cancel",
+ "halted": "report",
+ "done": "check_circle",
+}
+
+
+def current_theme_colors() -> dict[str, QColor]:
+ """
+ Colours of the active bec_qthemes theme, or a palette-based fallback when no theme is applied.
+
+ Returns:
+ dict[str, QColor]: Theme colours keyed by bec_qthemes palette key.
+ """
+ app = QApplication.instance()
+ if hasattr(app, "theme"):
+ return dict(app.theme._colors)
+ palette = app.palette()
+ colors = {k: QColor(v) for k, v in ACCENT_COLORS["dark"].items()}
+ colors["FG"] = palette.color(QPalette.ColorRole.WindowText)
+ colors["DISABLED_FG"] = palette.color(QPalette.ColorRole.PlaceholderText)
+ return colors
+
+
+def format_duration(seconds: float | None) -> str:
+ """
+ Format a duration compactly: ``m:ss`` below one hour, ``h:mm:ss`` above.
+
+ Args:
+ seconds(float | None): Duration in seconds.
+
+ Returns:
+ str: The formatted duration, or an em dash if unknown.
+ """
+ if seconds is None:
+ return "—"
+ seconds = max(0, int(round(seconds)))
+ hours, rest = divmod(seconds, 3600)
+ minutes, secs = divmod(rest, 60)
+ if hours:
+ return f"{hours}:{minutes:02}:{secs:02}"
+ return f"{minutes}:{secs:02}"
+
+
+def format_count(value: float) -> str:
+ """Format a point count without a trailing ``.0``."""
+ if float(value).is_integer():
+ return f"{int(value)}"
+ return f"{value:.1f}"
+
+
+class ScanProgressModel(QObject):
+ """
+ View-agnostic state of the current scan's progress.
+
+ Wraps ``BECProgressTracker`` and the scan status endpoint, and exposes ready-to-display
+ properties. Both the QML view and the QWidget view render from this object only.
+ """
+
+ changed = Signal()
+ progress_started = Signal()
+ progress_finished = Signal()
+
+ def __init__(self, bec_dispatcher, parent: QObject | None = None):
+ super().__init__(parent=parent)
+ self._dispatcher = bec_dispatcher
+ self._state = "idle"
+ self._value = 0.0
+ self._maximum = 0.0
+ self._scan_number: int | None = None
+ self._scan_name = ""
+ self._reason = ""
+ self._scan_id: str | None = None
+ self._elapsed: float | None = None
+ self._remaining: float | None = None
+
+ self.tracker = BECProgressTracker(bec_dispatcher, parent=self)
+ self.tracker.progress_started.connect(self._on_started)
+ self.tracker.progress_updated.connect(self._on_snapshot)
+ self.tracker.progress_finished.connect(self._on_finished)
+ self.tracker.start()
+ self._dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
+
+ # Elapsed/remaining tick once per second while a scan runs
+ self._tick_timer = QTimer(self)
+ self._tick_timer.setInterval(1000)
+ self._tick_timer.timeout.connect(self._tick)
+
+ ################################################################################
+ # Inputs
+
+ @SafeSlot(object)
+ def _on_started(self, _snapshot: ProgressSnapshot):
+ self._tick_timer.start()
+ self.progress_started.emit()
+
+ @SafeSlot(object)
+ def _on_snapshot(self, snapshot: ProgressSnapshot):
+ if snapshot.is_new_scan:
+ self._reason = ""
+ self._elapsed = 0.0
+ self._remaining = None
+ if snapshot.scan_id != self._scan_id:
+ self._scan_name = ""
+ self._scan_id = snapshot.scan_id or self._scan_id
+ self._scan_number = snapshot.scan_number
+ self._value = float(snapshot.value)
+ self._maximum = float(snapshot.max_value)
+ state = STATUS_TO_STATE.get(str(snapshot.status).lower(), "running")
+ if snapshot.done and state == "running":
+ state = "done"
+ # A final scan status (aborted/halted) wins over a late "open" progress message
+ if snapshot.is_new_scan or self._state not in ("aborted", "halted"):
+ self._state = state
+ self._read_task_times()
+ self.changed.emit()
+
+ @SafeSlot(object)
+ def _on_finished(self, _snapshot: ProgressSnapshot):
+ self._tick_timer.stop()
+ if self._state in ("running", "paused"):
+ self._state = "done"
+ self._remaining = 0.0
+ self.changed.emit()
+ self.progress_finished.emit()
+
+ @SafeSlot(dict, dict)
+ def _on_scan_status(self, msg_content: dict, metadata: dict):
+ scan_id = msg_content.get("scan_id")
+ status = msg_content.get("status")
+ if status == "open":
+ if scan_id != self._scan_id:
+ self._scan_id = scan_id
+ self._reason = ""
+ self._scan_name = msg_content.get("scan_name") or self._scan_name
+ if msg_content.get("scan_number") is not None:
+ self._scan_number = msg_content.get("scan_number")
+ self.changed.emit()
+ return
+ if scan_id is None or scan_id != self._scan_id:
+ return
+ new_state = STATUS_TO_STATE.get(status)
+ if new_state is None:
+ return
+ self._state = new_state
+ self._reason = msg_content.get("reason") or ""
+ if new_state in ("aborted", "halted", "done"):
+ self._tick_timer.stop()
+ self.changed.emit()
+
+ @SafeSlot()
+ def _tick(self):
+ self._read_task_times()
+ self.changed.emit()
+
+ def _read_task_times(self):
+ task = self.tracker.task
+ if task is None:
+ return
+ task.update_elapsed_time()
+ self._elapsed = task._elapsed_time
+ if self._state == "paused" or not task.speed or not task.remaining:
+ self._remaining = None if self._state != "done" else 0.0
+ else:
+ self._remaining = task.remaining / task.speed
+
+ ################################################################################
+ # Outputs
+
+ @Property(str, notify=changed)
+ def state(self) -> str:
+ """Display state: idle, running, paused, aborted, halted or done."""
+ return self._state
+
+ @Property(str, notify=changed)
+ def stateLabel(self) -> str:
+ label = STATE_LABELS[self._state]
+ if self._reason == "alarm" and self._state in ("aborted", "halted"):
+ label = f"{label} by alarm"
+ return label
+
+ @Property(str, notify=changed)
+ def stateColorKey(self) -> str:
+ return STATE_COLOR_KEYS[self._state]
+
+ @Property(str, notify=changed)
+ def stateIcon(self) -> str:
+ return STATE_ICONS[self._state]
+
+ @Property(bool, notify=changed)
+ def active(self) -> bool:
+ return self._state in ("running", "paused")
+
+ @Property(str, notify=changed)
+ def title(self) -> str:
+ """Scan identity, e.g. ``Scan 42 · line_scan``."""
+ if self._state == "idle" and self._scan_number is None:
+ return "No scan yet"
+ parts = [f"Scan {self._scan_number}" if self._scan_number is not None else "Scan"]
+ if self._scan_name:
+ parts.append(self._scan_name)
+ return " · ".join(parts)
+
+ @Property(float, notify=changed)
+ def value(self) -> float:
+ return self._value
+
+ @Property(float, notify=changed)
+ def maximum(self) -> float:
+ return self._maximum
+
+ @Property(float, notify=changed)
+ def fraction(self) -> float:
+ """Progress between 0 and 1."""
+ if self._maximum <= 0:
+ return 1.0 if self._state == "done" else 0.0
+ return min(1.0, max(0.0, self._value / self._maximum))
+
+ @Property(bool, notify=changed)
+ def indeterminate(self) -> bool:
+ """True while a scan runs but the number of points is not known yet."""
+ return self._state == "running" and self._maximum <= 0
+
+ @Property(str, notify=changed)
+ def percentText(self) -> str:
+ if self._state == "idle" or self.indeterminate:
+ return ""
+ return f"{int(self.fraction * 100)} %"
+
+ @Property(str, notify=changed)
+ def countText(self) -> str:
+ if self._state == "idle" or self._maximum <= 0:
+ return ""
+ return f"{format_count(self._value)} / {format_count(self._maximum)}"
+
+ @Property(str, notify=changed)
+ def elapsedText(self) -> str:
+ if self._state == "idle" or self._elapsed is None:
+ return ""
+ return format_duration(self._elapsed)
+
+ @Property(str, notify=changed)
+ def timeText(self) -> str:
+ """The single time hint shown at the right of the bar."""
+ if self._state == "idle":
+ return ""
+ if self._state == "running":
+ if self._remaining is None:
+ return "estimating…"
+ return f"{format_duration(self._remaining)} left"
+ if self._state == "paused":
+ return f"paused at {format_duration(self._elapsed)}"
+ return f"took {format_duration(self._elapsed)}"
+
+ @Property(str, notify=changed)
+ def toolTip(self) -> str:
+ if self._state == "idle":
+ return "No scan has reported progress yet."
+ lines = [f"{self.title} — {self.stateLabel}"]
+ if self.countText:
+ lines.append(f"Points: {self.countText} ({self.percentText})")
+ lines.append(f"Elapsed: {format_duration(self._elapsed)}")
+ if self._state == "running":
+ lines.append(f"Remaining: {format_duration(self._remaining)}")
+ return "\n".join(lines)
+
+ def cleanup(self):
+ """Disconnect from BEC and stop timers."""
+ self._tick_timer.stop()
+ self.tracker.cleanup()
+ self._dispatcher.disconnect_slot(self._on_scan_status, MessageEndpoints.scan_status())
diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar_modern.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar_modern.py
new file mode 100644
index 00000000..640a4000
--- /dev/null
+++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar_modern.py
@@ -0,0 +1,374 @@
+"""Scan progress bar with the modern UX, built from plain QWidgets on top of ScanProgressModel."""
+
+from __future__ import annotations
+
+from bec_qthemes import material_icon
+from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Signal
+from qtpy.QtGui import QColor, QFont, QLinearGradient, QPainter, QPainterPath
+from qtpy.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget
+
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.colors import get_theme_name
+from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
+from bec_widgets.widgets.progress.scan_progressbar.scan_progress_model import (
+ ScanProgressModel,
+ current_theme_colors,
+)
+
+
+def _theme_color(key: str, fallback: str = "#888888") -> QColor:
+ return current_theme_colors().get(key, QColor(fallback))
+
+
+class _ProgressTrack(QWidget):
+ """Rounded track with an animated fill, a running sheen and an indeterminate mode."""
+
+ def __init__(self, parent=None, height: int = 8):
+ super().__init__(parent)
+ self.setFixedHeight(height)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self._fraction = 0.0
+ self._color = QColor("#0a60ff")
+ self._running = False
+ self._indeterminate = False
+ self._phase = 0.0
+
+ self._anim = QPropertyAnimation(self, b"fraction", self)
+ self._anim.setDuration(250)
+ self._anim.setEasingCurve(QEasingCurve.Type.OutCubic)
+
+ # one timer drives the sheen and the indeterminate segment (~30 fps, only when needed)
+ self._phase_timer = QTimer(self)
+ self._phase_timer.setInterval(33)
+ self._phase_timer.timeout.connect(self._advance)
+
+ def get_fraction(self) -> float:
+ return self._fraction
+
+ def set_fraction(self, value: float):
+ self._fraction = value
+ self.update()
+
+ fraction = Property(float, get_fraction, set_fraction)
+
+ def set_state(self, fraction: float, color: QColor, running: bool, indeterminate: bool):
+ """Update target fraction, colour and animation mode."""
+ self._color = color
+ self._running = running
+ self._indeterminate = indeterminate
+ if abs(fraction - self._fraction) > 1e-4:
+ self._anim.stop()
+ self._anim.setStartValue(self._fraction)
+ self._anim.setEndValue(fraction)
+ self._anim.start()
+ animate = (running or indeterminate) and self.isVisible()
+ if animate and not self._phase_timer.isActive():
+ self._phase_timer.start()
+ elif not animate:
+ self._phase_timer.stop()
+ self.update()
+
+ def _advance(self):
+ self._phase = (self._phase + 0.033 / 1.6) % 1.0
+ self.update()
+
+ def hideEvent(self, event):
+ self._phase_timer.stop()
+ super().hideEvent(event)
+
+ def paintEvent(self, _event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ rect = QRectF(self.rect())
+ radius = rect.height() / 2
+ track = QColor(255, 255, 255, 20) if get_theme_name() == "dark" else QColor(0, 0, 0, 18)
+ clip = QPainterPath()
+ clip.addRoundedRect(rect, radius, radius)
+ painter.setClipPath(clip)
+ painter.fillPath(clip, track)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(self._color)
+
+ if self._indeterminate:
+ # triangle wave 0..1..0 for the sliding segment
+ t = self._phase * 2 if self._phase < 0.5 else 2 - self._phase * 2
+ seg = rect.width() * 0.3
+ painter.drawRoundedRect(
+ QRectF(t * (rect.width() - seg), 0, seg, rect.height()), radius, radius
+ )
+ return
+
+ if self._fraction <= 0:
+ return
+ width = max(rect.height(), rect.width() * self._fraction)
+ fill = QRectF(0, 0, width, rect.height())
+ painter.drawRoundedRect(fill, radius, radius)
+
+ if self._running and width > 24:
+ x = -40 + self._phase * (width + 40)
+ grad = QLinearGradient(x, 0, x + 40, 0)
+ grad.setColorAt(0.0, QColor(255, 255, 255, 0))
+ grad.setColorAt(0.5, QColor(255, 255, 255, 72))
+ grad.setColorAt(1.0, QColor(255, 255, 255, 0))
+ sheen = QPainterPath()
+ sheen.addRoundedRect(fill, radius, radius)
+ painter.fillPath(sheen, grad)
+
+
+class _StateChip(QWidget):
+ """Pill with a filled Material icon and the state label, tinted with the state colour."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._color = QColor("#888888")
+ self._icon = QLabel(self)
+ self._label = QLabel(self)
+ font = self._label.font()
+ font.setPixelSize(11)
+ font.setWeight(QFont.Weight.DemiBold)
+ self._label.setFont(font)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(7, 2, 8, 2)
+ layout.setSpacing(4)
+ layout.addWidget(self._icon)
+ layout.addWidget(self._label)
+ self.setFixedHeight(20)
+ self._icon_key = None
+
+ def set_state(self, label: str, icon: str, color: QColor):
+ self._color = color
+ # chip text needs more contrast than the bar fill on light backgrounds
+ ink = color if get_theme_name() == "dark" else color.darker(150)
+ self._label.setText(label)
+ _set_text_color(self._label, ink.name())
+ key = (icon, ink.name())
+ if key != self._icon_key:
+ self._icon.setPixmap(material_icon(icon, size=(14, 14), color=ink, filled=True))
+ self._icon_key = key
+ self.update()
+
+ def paintEvent(self, _event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ bg = QColor(self._color)
+ bg.setAlphaF(0.16)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(bg)
+ r = QRectF(self.rect())
+ painter.drawRoundedRect(r, r.height() / 2, r.height() / 2)
+
+
+class _Dot(QWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setFixedSize(8, 8)
+ self.color = QColor("#888888")
+
+ def paintEvent(self, _event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(self.color)
+ painter.drawEllipse(self.rect())
+
+
+def _set_text_color(label: QLabel, color: str):
+ """Set the label colour, skipping the costly stylesheet update when it is unchanged."""
+ if label.property("_text_color") != color:
+ label.setProperty("_text_color", color)
+ label.setStyleSheet(f"color: {color};")
+
+
+def _small_label(parent, size: int = 11, bold: bool = False) -> QLabel:
+ label = QLabel(parent)
+ font = label.font()
+ font.setPixelSize(size)
+ if bold:
+ font.setWeight(QFont.Weight.DemiBold)
+ label.setFont(font)
+ return label
+
+
+class ScanProgressBarModern(BECWidget, QWidget):
+ """
+ Scan progress bar with a state chip, smooth bar and time estimate, built from QWidgets.
+
+ Same UX as ``ScanProgressBarQml`` and the same public API as ``ScanProgressBar``.
+ """
+
+ ICON_NAME = "timelapse"
+ PLUGIN = False
+ RPC = False
+ progress_started = Signal()
+ progress_finished = Signal()
+
+ def __init__(
+ self, parent=None, client=None, config=None, gui_id=None, one_line_design=False, **kwargs
+ ):
+ kwargs.pop("enable_dynamic_stylesheet", None)
+ super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
+ self.get_bec_shortcuts()
+ self._compact = bool(one_line_design)
+ self._show_elapsed = True
+ self._show_remaining = True
+ self._show_source = True
+
+ self.model = ScanProgressModel(self.bec_dispatcher, parent=self)
+ self.model.progress_started.connect(self.progress_started)
+ self.model.progress_finished.connect(self.progress_finished)
+ self.model.changed.connect(self._render)
+
+ if self._compact:
+ self._build_compact()
+ else:
+ self._build_full()
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self._render()
+
+ @property
+ def progress_tracker(self):
+ """The underlying BECProgressTracker."""
+ return self.model.tracker
+
+ def _build_full(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(4, 4, 4, 4)
+ layout.setSpacing(4)
+
+ top = QHBoxLayout()
+ top.setSpacing(8)
+ self.chip = _StateChip(self)
+ self.title_label = _small_label(self, 12)
+ self.title_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
+ self.percent_label = _small_label(self, 15, bold=True)
+ top.addWidget(self.chip)
+ top.addWidget(self.title_label, 1)
+ top.addWidget(self.percent_label)
+
+ self.track = _ProgressTrack(self, height=8)
+
+ bottom = QHBoxLayout()
+ bottom.setSpacing(6)
+ self.count_label = _small_label(self)
+ self.elapsed_label = _small_label(self)
+ self.dot_label = _small_label(self)
+ self.dot_label.setText("·")
+ self.time_label = _small_label(self)
+ bottom.addWidget(self.count_label)
+ bottom.addStretch(1)
+ bottom.addWidget(self.elapsed_label)
+ bottom.addWidget(self.dot_label)
+ bottom.addWidget(self.time_label)
+
+ layout.addLayout(top)
+ layout.addWidget(self.track)
+ layout.addLayout(bottom)
+ self.setFixedHeight(58)
+
+ def _build_compact(self):
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(4, 0, 4, 0)
+ layout.setSpacing(6)
+ self.dot = _Dot(self)
+ self.title_label = _small_label(self)
+ self.title_label.setMaximumWidth(120)
+ self.track = _ProgressTrack(self, height=6)
+ self.percent_label = _small_label(self)
+ self.time_label = _small_label(self)
+ for w in (self.dot, self.title_label, self.track, self.percent_label, self.time_label):
+ layout.addWidget(w, 1 if w is self.track else 0, Qt.AlignmentFlag.AlignVCenter)
+ self.setFixedHeight(22)
+
+ @SafeSlot()
+ def _render(self):
+ m = self.model
+ color = _theme_color(m.stateColorKey)
+ fg = _theme_color("FG", "#e0e0e0").name()
+ muted = _theme_color("DISABLED_FG").name()
+ self.track.set_state(m.fraction, color, m.state == "running", m.indeterminate)
+ self.title_label.setText(
+ self.title_label.fontMetrics().elidedText(
+ m.title, Qt.TextElideMode.ElideRight, max(40, self.title_label.width())
+ )
+ if not self._compact
+ else m.title
+ )
+ self.title_label.setVisible(self._show_source)
+ self.percent_label.setText(m.percentText)
+ self.time_label.setText(m.timeText)
+ self.time_label.setVisible(self._show_remaining and bool(m.timeText))
+ self.setToolTip(m.toolTip)
+
+ if self._compact:
+ self.dot.color = color
+ self.dot.update()
+ for lbl in (self.title_label, self.percent_label):
+ _set_text_color(lbl, fg)
+ _set_text_color(self.time_label, muted)
+ return
+
+ self.chip.set_state(m.stateLabel, m.stateIcon, color)
+ _set_text_color(self.title_label, muted if m.state == "idle" else fg)
+ _set_text_color(self.percent_label, fg)
+ self.count_label.setText(f"{m.countText} points" if m.countText else "")
+ show_elapsed = self._show_elapsed and m.active and bool(m.elapsedText)
+ self.elapsed_label.setText(f"{m.elapsedText} elapsed")
+ self.elapsed_label.setVisible(show_elapsed)
+ self.dot_label.setVisible(show_elapsed and self.time_label.isVisibleTo(self))
+ for lbl in (self.count_label, self.elapsed_label, self.dot_label):
+ _set_text_color(lbl, muted)
+ _set_text_color(self.time_label, fg if m.state == "running" else muted)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ if not self._compact:
+ self._render()
+
+ def apply_theme(self, theme: str):
+ self._render()
+
+ @SafeProperty(bool)
+ def show_elapsed_time(self):
+ return self._show_elapsed
+
+ @show_elapsed_time.setter
+ def show_elapsed_time(self, value):
+ self._show_elapsed = bool(value)
+ self._render()
+
+ @SafeProperty(bool)
+ def show_remaining_time(self):
+ return self._show_remaining
+
+ @show_remaining_time.setter
+ def show_remaining_time(self, value):
+ self._show_remaining = bool(value)
+ self._render()
+
+ @SafeProperty(bool)
+ def show_source_label(self):
+ return self._show_source
+
+ @show_source_label.setter
+ def show_source_label(self, value):
+ self._show_source = bool(value)
+ self._render()
+
+ def update_labels(self):
+ """Kept for API compatibility; the view updates itself from the model."""
+ self.model._tick()
+
+ def cleanup(self):
+ self.model.cleanup()
+ self.track._phase_timer.stop()
+ self.track._anim.stop()
+ super().cleanup()
+
+
+if __name__ == "__main__": # pragma: no cover
+ from qtpy.QtWidgets import QApplication
+
+ app = QApplication([])
+ widget = ScanProgressBarModern()
+ widget.show()
+ app.exec_()
diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar_qml.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar_qml.py
new file mode 100644
index 00000000..e34cb04f
--- /dev/null
+++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar_qml.py
@@ -0,0 +1,110 @@
+"""Scan progress bar rendered with QML (QQuickWidget) on top of ScanProgressModel."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from qtpy.QtCore import Signal
+from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget
+
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
+from bec_widgets.widgets.progress.scan_progressbar.qml_support import create_quick_widget
+from bec_widgets.widgets.progress.scan_progressbar.scan_progress_model import ScanProgressModel
+
+QML_FILE = Path(__file__).parent / "qml" / "ScanProgress.qml"
+
+
+class ScanProgressBarQml(BECWidget, QWidget):
+ """
+ Scan progress bar with a state chip, smooth bar and time estimate, drawn in QML.
+
+ Same public API as ``ScanProgressBar``: ``progress_started``/``progress_finished`` signals,
+ ``one_line_design`` and the ``show_*`` properties.
+ """
+
+ ICON_NAME = "timelapse"
+ PLUGIN = False
+ RPC = False
+ progress_started = Signal()
+ progress_finished = Signal()
+
+ def __init__(
+ self, parent=None, client=None, config=None, gui_id=None, one_line_design=False, **kwargs
+ ):
+ kwargs.pop("enable_dynamic_stylesheet", None)
+ super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
+ self.get_bec_shortcuts()
+ self.model = ScanProgressModel(self.bec_dispatcher, parent=self)
+ self.model.progress_started.connect(self.progress_started)
+ self.model.progress_finished.connect(self.progress_finished)
+ self.model.changed.connect(self._update_tooltip)
+
+ self.view = create_quick_widget(self, QML_FILE, {"progressModel": self.model})
+ # the view's root item must be destroyed before the model it binds to
+ self.model.setParent(self.view)
+ self.view.rootObject().setProperty("compact", bool(one_line_design))
+ self.view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self.setFixedHeight(22 if one_line_design else 58)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(self.view)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
+ self._update_tooltip()
+
+ @property
+ def progress_tracker(self):
+ """The underlying BECProgressTracker."""
+ return self.model.tracker
+
+ @SafeSlot()
+ def _update_tooltip(self):
+ self.view.setToolTip(self.model.toolTip)
+
+ def _root_flag(self, name: str) -> bool:
+ return bool(self.view.rootObject().property(name))
+
+ @SafeProperty(bool)
+ def show_elapsed_time(self):
+ return self._root_flag("showElapsed")
+
+ @show_elapsed_time.setter
+ def show_elapsed_time(self, value):
+ self.view.rootObject().setProperty("showElapsed", bool(value))
+
+ @SafeProperty(bool)
+ def show_remaining_time(self):
+ return self._root_flag("showRemaining")
+
+ @show_remaining_time.setter
+ def show_remaining_time(self, value):
+ self.view.rootObject().setProperty("showRemaining", bool(value))
+
+ @SafeProperty(bool)
+ def show_source_label(self):
+ return self._root_flag("showSource")
+
+ @show_source_label.setter
+ def show_source_label(self, value):
+ self.view.rootObject().setProperty("showSource", bool(value))
+
+ def update_labels(self):
+ """Kept for API compatibility; the view updates itself from the model."""
+ self.model._tick()
+
+ def cleanup(self):
+ self.model.cleanup()
+ self.view.setSource("") # release QML objects before the context goes away
+ self.view.close()
+ self.view.deleteLater()
+ super().cleanup()
+
+
+if __name__ == "__main__": # pragma: no cover
+ from qtpy.QtWidgets import QApplication
+
+ app = QApplication([])
+ widget = ScanProgressBarQml()
+ widget.show()
+ app.exec_()
diff --git a/tests/unit_tests/test_scan_progress_bar_modern.py b/tests/unit_tests/test_scan_progress_bar_modern.py
new file mode 100644
index 00000000..b4e62d44
--- /dev/null
+++ b/tests/unit_tests/test_scan_progress_bar_modern.py
@@ -0,0 +1,117 @@
+import pytest
+
+from bec_widgets.tests.client_mocks import mocked_client
+from bec_widgets.tests.utils import create_widget
+from bec_widgets.widgets.progress.scan_progressbar.scan_progress_model import (
+ format_count,
+ format_duration,
+)
+from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar_modern import (
+ ScanProgressBarModern,
+)
+from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar_qml import ScanProgressBarQml
+
+
+@pytest.fixture(params=[ScanProgressBarModern, ScanProgressBarQml], ids=["qwidget", "qml"])
+def widget_cls(request):
+ return request.param
+
+
+@pytest.fixture
+def bar(qtbot, mocked_client, widget_cls):
+ return create_widget(qtbot, widget_cls, client=mocked_client)
+
+
+def _status(bar, status, scan_id="sid", **extra):
+ bar.model._on_scan_status({"scan_id": scan_id, "status": status, **extra}, {})
+
+
+def _progress(bar, value, max_value, done=False, status="open", scan_id="sid", scan_number=7):
+ bar.progress_tracker.process_progress_message(
+ {"value": value, "max_value": max_value, "done": done},
+ {"scan_id": scan_id, "RID": f"rid-{scan_id}", "status": status, "scan_number": scan_number},
+ )
+
+
+def test_format_helpers():
+ assert format_duration(None) == "—"
+ assert format_duration(5) == "0:05"
+ assert format_duration(83) == "1:23"
+ assert format_duration(3725) == "1:02:05"
+ assert format_count(3.0) == "3"
+ assert format_count(2.5) == "2.5"
+
+
+def test_idle_state(bar):
+ m = bar.model
+ assert m.state == "idle"
+ assert m.title == "No scan yet"
+ assert m.percentText == ""
+ assert m.timeText == ""
+
+
+def test_running_scan_shows_name_count_and_percent(bar):
+ _status(bar, "open", scan_name="line_scan", scan_number=7)
+ _progress(bar, 3, 12)
+ m = bar.model
+ assert m.state == "running"
+ assert m.title == "Scan 7 · line_scan"
+ assert m.countText == "3 / 12"
+ assert m.percentText == "25 %"
+ assert m.fraction == pytest.approx(0.25)
+ assert m.timeText in ("estimating…",) or m.timeText.endswith("left")
+
+
+def test_indeterminate_until_points_are_known(bar):
+ _progress(bar, 0, 0)
+ assert bar.model.indeterminate
+ _progress(bar, 1, 10)
+ assert not bar.model.indeterminate
+
+
+def test_paused_then_resumed(bar):
+ _progress(bar, 2, 10)
+ _progress(bar, 2, 10, status="paused")
+ assert bar.model.state == "paused"
+ assert bar.model.timeText.startswith("paused at")
+ _progress(bar, 3, 10)
+ assert bar.model.state == "running"
+
+
+def test_done_keeps_result_visible(bar, qtbot):
+ with qtbot.waitSignal(bar.progress_finished, timeout=1000):
+ _progress(bar, 10, 10, done=True)
+ m = bar.model
+ assert m.state == "done"
+ assert m.percentText == "100 %"
+ assert m.timeText.startswith("took")
+
+
+def test_halted_by_alarm_is_not_overwritten_by_late_progress(bar):
+ _progress(bar, 4, 10)
+ _status(bar, "halted", reason="alarm")
+ assert bar.model.stateLabel == "Halted by alarm"
+ _progress(bar, 4, 10, done=True)
+ assert bar.model.state == "halted"
+
+
+def test_new_scan_resets_state(bar):
+ _progress(bar, 4, 10)
+ _status(bar, "aborted")
+ _progress(bar, 1, 5, scan_id="next", scan_number=8)
+ assert bar.model.state == "running"
+ assert bar.model.title.startswith("Scan 8")
+
+
+def test_show_properties(bar):
+ for name in ("show_elapsed_time", "show_remaining_time", "show_source_label"):
+ assert getattr(bar, name) is True
+ setattr(bar, name, False)
+ assert getattr(bar, name) is False
+
+
+def test_one_line_design(qtbot, mocked_client, widget_cls):
+ widget = create_widget(qtbot, widget_cls, client=mocked_client, one_line_design=True)
+ _progress(widget, 1, 2)
+ assert widget.height() == 22
+ assert widget.model.percentText == "50 %"