mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
feat(welcome_view): mock up three welcome screen directions as an opt-in Home view in bec-app
This commit is contained in:
6 files changed
+1576
-1
No files matched your search
@@ -13,6 +13,10 @@ from bec_widgets.applications.views.developer_view.developer_view import Develop
|
||||
from bec_widgets.applications.views.device_manager_view.device_manager_view import DeviceManagerView
|
||||
from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView
|
||||
from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup
|
||||
from bec_widgets.applications.views.welcome_view.welcome_view import (
|
||||
WelcomeView,
|
||||
welcome_view_requested,
|
||||
)
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.guided_tour import GuidedTour
|
||||
from bec_widgets.utils.launcher_ready import notify_launcher_ready
|
||||
@@ -87,6 +91,17 @@ class BECMainApp(BECMainWindow):
|
||||
self.admin_view = AdminView(self)
|
||||
startup_profiler.mark("AdminView")
|
||||
|
||||
self.welcome_view = None
|
||||
welcome_direction = welcome_view_requested()
|
||||
if welcome_direction is not None:
|
||||
self.welcome_view = WelcomeView(self, direction=welcome_direction)
|
||||
self.add_view(
|
||||
icon="home",
|
||||
title="Home",
|
||||
view_id="welcome",
|
||||
widget=self.welcome_view,
|
||||
mini_text="Home",
|
||||
)
|
||||
self.add_view(icon="widgets", title="Dock Area", widget=self.dock_area, mini_text="Docks")
|
||||
self.add_view(
|
||||
icon="display_settings",
|
||||
@@ -133,7 +148,7 @@ class BECMainApp(BECMainWindow):
|
||||
mini_text="Stack",
|
||||
)
|
||||
|
||||
self.set_current("dock_area")
|
||||
self.set_current("welcome" if self.welcome_view is not None else "dock_area")
|
||||
self.sidebar.add_dark_mode_item()
|
||||
|
||||
# Add guided tour to Help menu
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,466 @@
|
||||
"""Data shown on the welcome view.
|
||||
|
||||
The view only renders a :class:`WelcomeSnapshot`. :func:`demo_snapshot` returns a fixed, realistic
|
||||
shift so the three design directions can be compared on the same content. A live provider would
|
||||
fill the same dataclasses from BEC:
|
||||
|
||||
* account: ``MessageEndpoints.account()``
|
||||
* queue and running scan: ``MessageEndpoints.scan_queue_status()`` and ``scan_progress()``
|
||||
* recent scans: ``MessageEndpoints.scan_history()``
|
||||
* services: ``MessageEndpoints.service_status(...)`` (as ``BECStatusBox`` does)
|
||||
* interlock: ``MessageEndpoints.available_beamline_states()`` and ``beamline_state(name)``
|
||||
* problems: ``MessageEndpoints.alarm()`` and the notification centre
|
||||
* workspaces: the dock area profile store (``profile_utils``)
|
||||
|
||||
Experiment title, beamtime dates and the machine status are not BEC endpoints today; they are
|
||||
open questions for the design discussion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
|
||||
@dataclass
|
||||
class Experiment:
|
||||
"""Who is measuring and on what."""
|
||||
|
||||
beamline: str
|
||||
account: str
|
||||
title: str
|
||||
beamtime: str
|
||||
day: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Glance:
|
||||
"""One-line state of a subsystem, shown as a tile or a chip."""
|
||||
|
||||
key: str
|
||||
icon: str
|
||||
label: str
|
||||
value: str
|
||||
detail: str
|
||||
tone: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunningScan:
|
||||
"""The scan that is running right now."""
|
||||
|
||||
number: int
|
||||
scan_type: str
|
||||
summary: str
|
||||
sample: str
|
||||
done: int
|
||||
total: int
|
||||
elapsed: str
|
||||
remaining: str
|
||||
paused: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedScan:
|
||||
"""A scan waiting in the queue."""
|
||||
|
||||
scan_type: str
|
||||
summary: str
|
||||
sample: str = ""
|
||||
estimate: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecentScan:
|
||||
"""A finished scan with what is needed to jump back to it."""
|
||||
|
||||
number: int
|
||||
scan_type: str
|
||||
summary: str
|
||||
sample: str
|
||||
status: str
|
||||
tone: str
|
||||
duration: str
|
||||
finished: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Problem:
|
||||
"""Something that needs a person: an error, a warning, a tripped state."""
|
||||
|
||||
tone: str
|
||||
title: str
|
||||
text: str
|
||||
when: str
|
||||
action: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workspace:
|
||||
"""A saved dock area profile the user can reopen."""
|
||||
|
||||
name: str
|
||||
opened: str
|
||||
docks: list[str]
|
||||
current: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuickAction:
|
||||
"""A shortcut into another part of the app."""
|
||||
|
||||
icon: str
|
||||
title: str
|
||||
text: str
|
||||
shortcut: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""One line of the activity feed."""
|
||||
|
||||
time: str
|
||||
kind: str # scan, problem, interlock, config, note, session
|
||||
icon: str
|
||||
tone: str
|
||||
title: str
|
||||
text: str = ""
|
||||
action: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WelcomeSnapshot:
|
||||
"""Everything the welcome view shows at one moment."""
|
||||
|
||||
experiment: Experiment
|
||||
greeting: str
|
||||
glances: list[Glance]
|
||||
running: RunningScan | None
|
||||
queued: list[QueuedScan]
|
||||
recent: list[RecentScan]
|
||||
problems: list[Problem]
|
||||
workspaces: list[Workspace]
|
||||
actions: list[QuickAction]
|
||||
events: list[Event] = field(default_factory=list)
|
||||
away_since: str = ""
|
||||
away_summary: list[tuple[str, str, str]] = field(default_factory=list)
|
||||
tip: tuple[str, str] = ("", "")
|
||||
|
||||
|
||||
def demo_snapshot() -> WelcomeSnapshot:
|
||||
"""A realistic evening shift on day three of a beamtime, used for the mockups."""
|
||||
return WelcomeSnapshot(
|
||||
experiment=Experiment(
|
||||
beamline="X12SA · cSAXS",
|
||||
account="p20631",
|
||||
title="Operando SAXS of battery cathodes",
|
||||
beamtime="7 – 10 Oct",
|
||||
day="Day 3 of 4",
|
||||
),
|
||||
greeting="Good evening",
|
||||
glances=[
|
||||
Glance("machine", "bolt", "Machine", "401 mA", "Top-up · light available", "success"),
|
||||
Glance(
|
||||
"interlock", "verified_user", "Scan interlock", "Armed", "2 of 2 valid", "success"
|
||||
),
|
||||
Glance(
|
||||
"services",
|
||||
"monitor_heart",
|
||||
"BEC services",
|
||||
"All running",
|
||||
"1 version mismatch",
|
||||
"warning",
|
||||
),
|
||||
Glance("queue", "playlist_play", "Queue", "Running", "2 waiting · ~14 min", "info"),
|
||||
],
|
||||
running=RunningScan(
|
||||
number=1291,
|
||||
scan_type="line_scan",
|
||||
summary="samx −2 → 2 · 41 pts · 0.5 s",
|
||||
sample="LaB6",
|
||||
done=25,
|
||||
total=41,
|
||||
elapsed="0:52",
|
||||
remaining="0:33",
|
||||
),
|
||||
queued=[
|
||||
QueuedScan("grid_scan", "samx −5 → 5 (41) · samy −2 → 2 (11)", "LaB6", "~11 min"),
|
||||
QueuedScan("line_scan", "mono_energy 12.3 → 12.5 keV · 101 pts", "", "~3 min"),
|
||||
],
|
||||
recent=[
|
||||
RecentScan(
|
||||
1290,
|
||||
"line_scan",
|
||||
"samy −1 → 1 · 21 pts",
|
||||
"LaB6",
|
||||
"Aborted",
|
||||
"warning",
|
||||
"0:24",
|
||||
"21:31",
|
||||
),
|
||||
RecentScan(
|
||||
1289,
|
||||
"fermat_scan",
|
||||
"samx, samy ±3 · step 0.5",
|
||||
"LaB6",
|
||||
"Done",
|
||||
"success",
|
||||
"6:12",
|
||||
"21:24",
|
||||
),
|
||||
RecentScan(
|
||||
1288, "acquire", "eiger9m · 1 s", "LaB6", "Done", "success", "0:01", "21:17"
|
||||
),
|
||||
RecentScan(
|
||||
1287,
|
||||
"grid_scan",
|
||||
"samx ±5 (41) · samy ±2 (11)",
|
||||
"cathode_A3",
|
||||
"Done",
|
||||
"success",
|
||||
"11:40",
|
||||
"21:05",
|
||||
),
|
||||
RecentScan(
|
||||
1286,
|
||||
"line_scan",
|
||||
"mono_energy 12.3 → 12.5 keV",
|
||||
"cathode_A3",
|
||||
"Failed",
|
||||
"danger",
|
||||
"1:02",
|
||||
"20:41",
|
||||
),
|
||||
RecentScan(
|
||||
1285,
|
||||
"line_scan",
|
||||
"samx −2 → 2 · 41 pts",
|
||||
"cathode_A3",
|
||||
"Done",
|
||||
"success",
|
||||
"0:58",
|
||||
"20:33",
|
||||
),
|
||||
],
|
||||
problems=[
|
||||
Problem(
|
||||
"warning",
|
||||
"sample_temp is close to its upper limit",
|
||||
"bpm4i reads 9.6, limit 10. Not part of the scan interlock.",
|
||||
"2 min ago",
|
||||
"Beamline states",
|
||||
),
|
||||
Problem(
|
||||
"danger",
|
||||
"Scan 1286 failed: mono_energy readback timed out",
|
||||
"DeviceServer · mono_energy did not reach 12.41 keV within 10 s.",
|
||||
"20:41",
|
||||
"Details",
|
||||
),
|
||||
Problem(
|
||||
"info",
|
||||
"IPython client on x12sa-console-2 runs v3.38.1",
|
||||
"The servers run v3.39.0. Restart that client to update.",
|
||||
"18:02",
|
||||
"",
|
||||
),
|
||||
],
|
||||
workspaces=[
|
||||
Workspace("Alignment", "open now", ["Waveform", "Image", "PositionerBox"], True),
|
||||
Workspace("Scan monitor", "today 19:12", ["Waveform", "Waveform", "Queue", "Ring"]),
|
||||
Workspace("Detector QA", "yesterday", ["Image", "Image", "Heatmap"]),
|
||||
Workspace("Energy calibration", "7 Oct", ["Waveform", "ScanControl"]),
|
||||
],
|
||||
actions=[
|
||||
QuickAction(
|
||||
"play_circle", "Run a scan", "Scan control with your last settings", "Ctrl+R"
|
||||
),
|
||||
QuickAction(
|
||||
"search", "Find anything", "Devices, scans, widgets and commands", "Ctrl+K"
|
||||
),
|
||||
QuickAction("display_settings", "Device manager", "Enable, disable or edit devices"),
|
||||
QuickAction(
|
||||
"dashboard_customize", "New workspace", "Start from an empty dock area", "Ctrl+T"
|
||||
),
|
||||
],
|
||||
events=[
|
||||
Event(
|
||||
"21:52",
|
||||
"scan",
|
||||
"play_arrow",
|
||||
"info",
|
||||
"Scan 1291 started",
|
||||
"line_scan · samx −2 → 2 · LaB6",
|
||||
"Live plot",
|
||||
),
|
||||
Event(
|
||||
"21:31",
|
||||
"scan",
|
||||
"stop_circle",
|
||||
"warning",
|
||||
"Scan 1290 aborted by e20631",
|
||||
"line_scan · samy −1 → 1 · 12 of 21 points",
|
||||
"Plot",
|
||||
),
|
||||
Event(
|
||||
"21:24",
|
||||
"scan",
|
||||
"check_circle",
|
||||
"success",
|
||||
"Scan 1289 finished",
|
||||
"fermat_scan · 6 min 12 s · LaB6",
|
||||
"Plot",
|
||||
),
|
||||
Event(
|
||||
"21:10",
|
||||
"config",
|
||||
"tune",
|
||||
"neutral",
|
||||
"Sample changed to LaB6",
|
||||
"sample_name metadata updated by e20631",
|
||||
),
|
||||
Event(
|
||||
"21:05",
|
||||
"scan",
|
||||
"check_circle",
|
||||
"success",
|
||||
"Scan 1287 finished",
|
||||
"grid_scan · 11 min 40 s · cathode_A3",
|
||||
"Plot",
|
||||
),
|
||||
Event(
|
||||
"20:58",
|
||||
"interlock",
|
||||
"lock_open",
|
||||
"success",
|
||||
"Scan interlock re-armed",
|
||||
"shutter_open is valid again after 4 min",
|
||||
),
|
||||
Event(
|
||||
"20:54",
|
||||
"interlock",
|
||||
"lock",
|
||||
"danger",
|
||||
"Scan interlock tripped",
|
||||
"shutter_open outside [0, 10] · queue paused",
|
||||
"Beamline states",
|
||||
),
|
||||
Event(
|
||||
"20:41",
|
||||
"problem",
|
||||
"error",
|
||||
"danger",
|
||||
"Scan 1286 failed",
|
||||
"mono_energy readback timed out",
|
||||
"Details",
|
||||
),
|
||||
Event(
|
||||
"20:33",
|
||||
"scan",
|
||||
"done_all",
|
||||
"success",
|
||||
"6 more scans finished",
|
||||
"1281 – 1285 and 1288 · cathode_A3, LaB6",
|
||||
"Show",
|
||||
),
|
||||
Event(
|
||||
"19:30",
|
||||
"note",
|
||||
"sticky_note_2",
|
||||
"info",
|
||||
"Note from beamline staff",
|
||||
"Mono recalibrated at 19:00. Expect a 2 eV offset on old energy scans.",
|
||||
),
|
||||
Event("19:12", "config", "dashboard", "neutral", "Workspace 'Scan monitor' saved"),
|
||||
Event(
|
||||
"18:02",
|
||||
"session",
|
||||
"login",
|
||||
"neutral",
|
||||
"e20631 started bec on x12sa-console-2",
|
||||
"client v3.38.1, servers v3.39.0",
|
||||
),
|
||||
],
|
||||
away_since="17:45",
|
||||
away_summary=[
|
||||
("check_circle", "success", "8 scans finished"),
|
||||
("stop_circle", "warning", "1 aborted"),
|
||||
("error", "danger", "1 failed"),
|
||||
("lock", "danger", "Interlock tripped once"),
|
||||
("sticky_note_2", "info", "1 staff note"),
|
||||
],
|
||||
tip=(
|
||||
"Re-run any scan from its history",
|
||||
"Right-click a scan in the history and choose Re-run to open it in scan control "
|
||||
"with the same arguments.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def trouble_snapshot() -> WelcomeSnapshot:
|
||||
"""The same shift a few minutes later, when the interlock trips and a service goes down."""
|
||||
snap = demo_snapshot()
|
||||
snap.glances = [
|
||||
snap.glances[0],
|
||||
Glance(
|
||||
"interlock",
|
||||
"gpp_bad",
|
||||
"Scan interlock",
|
||||
"Tripped",
|
||||
"shutter_open outside [0, 10]",
|
||||
"danger",
|
||||
),
|
||||
Glance(
|
||||
"services",
|
||||
"monitor_heart",
|
||||
"BEC services",
|
||||
"1 down",
|
||||
"File writer not responding",
|
||||
"danger",
|
||||
),
|
||||
Glance(
|
||||
"queue", "pause_circle", "Queue", "Paused", "by the interlock · 2 waiting", "warning"
|
||||
),
|
||||
]
|
||||
snap.running = replace(snap.running, paused=True)
|
||||
snap.problems = [
|
||||
Problem(
|
||||
"danger",
|
||||
"Scans are blocked: the scan interlock tripped",
|
||||
"shutter_open reads 12.3, accepted range [0, 10]. The queue resumes when it is "
|
||||
"valid again.",
|
||||
"22:03",
|
||||
"Beamline states",
|
||||
),
|
||||
Problem(
|
||||
"danger",
|
||||
"File writer is not responding",
|
||||
"Last heartbeat 40 s ago on x12sa-bec-01. Data of scan 1291 may be incomplete.",
|
||||
"22:02",
|
||||
"Service status",
|
||||
),
|
||||
] + snap.problems[:1]
|
||||
snap.events = [
|
||||
Event(
|
||||
"22:03",
|
||||
"interlock",
|
||||
"lock",
|
||||
"danger",
|
||||
"Scan interlock tripped",
|
||||
"shutter_open outside [0, 10] · scan 1291 paused at point 25",
|
||||
"Beamline states",
|
||||
),
|
||||
Event(
|
||||
"22:02",
|
||||
"problem",
|
||||
"error",
|
||||
"danger",
|
||||
"File writer is not responding",
|
||||
"last heartbeat 40 s ago on x12sa-bec-01",
|
||||
"Service status",
|
||||
),
|
||||
] + snap.events
|
||||
snap.away_summary = snap.away_summary[:3] + [
|
||||
("lock", "danger", "Interlock tripped twice"),
|
||||
("error", "danger", "File writer down"),
|
||||
]
|
||||
return snap
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Welcome view for the BEC main app: a rundown of what is happening at the beamline.
|
||||
|
||||
Three design directions render the same :class:`WelcomeSnapshot`, so they can be compared:
|
||||
|
||||
* ``board``: status board. Glance tiles, the running scan and queue, recent scans, problems,
|
||||
workspaces and quick actions on one screen.
|
||||
* ``launchpad``: a calm start page. Greeting, one status line, recent workspaces as large
|
||||
previews and a few big actions.
|
||||
* ``feed``: shift handover. What is true right now on the left, everything that happened since
|
||||
you were last here on the right.
|
||||
|
||||
This is a mockup: it shows :func:`demo_snapshot` data and is only added to the app when
|
||||
``BEC_WELCOME_VIEW`` is set (``board``, ``launchpad`` or ``feed`` picks the first direction).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.applications.views.view import ViewBase
|
||||
from bec_widgets.applications.views.welcome_view.welcome_data import WelcomeSnapshot, demo_snapshot
|
||||
from bec_widgets.applications.views.welcome_view.welcome_widgets import (
|
||||
ActionRow,
|
||||
ActionTile,
|
||||
GlanceTile,
|
||||
QueueRow,
|
||||
RecentScansTable,
|
||||
RunningScanBlock,
|
||||
StatusChip,
|
||||
SummaryChip,
|
||||
TimelineRow,
|
||||
WorkspaceRow,
|
||||
WorkspaceTile,
|
||||
caption,
|
||||
hline,
|
||||
icon_badge,
|
||||
icon_label,
|
||||
label,
|
||||
section_card,
|
||||
tokens,
|
||||
vline,
|
||||
)
|
||||
from bec_widgets.utils.quick.tokens import app_theme
|
||||
from bec_widgets.utils.ux_kit import (
|
||||
Banner,
|
||||
Card,
|
||||
LinearProgress,
|
||||
SegmentedControl,
|
||||
StatusPill,
|
||||
TextButton,
|
||||
)
|
||||
|
||||
DIRECTIONS = [("board", "Status board"), ("launchpad", "Launchpad"), ("feed", "Activity feed")]
|
||||
ENV_FLAG = "BEC_WELCOME_VIEW"
|
||||
|
||||
|
||||
def welcome_view_requested() -> str | None:
|
||||
"""The direction requested through ``BEC_WELCOME_VIEW``, or None when the view is off."""
|
||||
value = os.environ.get(ENV_FLAG, "").strip().lower()
|
||||
if not value or value in ("0", "off", "false", "no"):
|
||||
return None
|
||||
keys = [key for key, _ in DIRECTIONS]
|
||||
return value if value in keys else keys[0]
|
||||
|
||||
|
||||
def _scroll(widget: QWidget) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
area.setWidget(widget)
|
||||
area.setStyleSheet("QScrollArea { background: transparent; }")
|
||||
area.viewport().setStyleSheet("background: transparent;")
|
||||
return area
|
||||
|
||||
|
||||
def _page() -> tuple[QWidget, QVBoxLayout]:
|
||||
page = QWidget()
|
||||
page.setObjectName("welcomePage")
|
||||
page.setStyleSheet(f"QWidget#welcomePage {{ background: {tokens().bg.name()}; }}")
|
||||
lay = QVBoxLayout(page)
|
||||
lay.setContentsMargins(28, 18, 28, 24)
|
||||
lay.setSpacing(14)
|
||||
return page, lay
|
||||
|
||||
|
||||
def _experiment_header(snap: WelcomeSnapshot, big: bool = False) -> QWidget:
|
||||
t = tokens()
|
||||
exp = snap.experiment
|
||||
box = QWidget()
|
||||
col = QVBoxLayout(box)
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.setSpacing(4)
|
||||
if big:
|
||||
col.addWidget(label(f"{snap.greeting}, e{exp.account[1:]}", 28, 650))
|
||||
col.addWidget(
|
||||
label(f"{exp.account} · {exp.title} · {exp.beamline} · {exp.day}", 14, 400, t.fg_muted)
|
||||
)
|
||||
return box
|
||||
col.addWidget(caption(exp.beamline))
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
row.addWidget(label(exp.title, 20, 650))
|
||||
row.addWidget(StatusPill(text=exp.account, tone="primary", icon_name="badge"))
|
||||
row.addWidget(StatusPill(text=exp.day, tone="neutral", outlined=True))
|
||||
row.addStretch(1)
|
||||
col.addLayout(row)
|
||||
col.addWidget(
|
||||
label(
|
||||
f"Beamtime {exp.beamtime} · logged in as e{exp.account[1:]} on x12sa-console-2",
|
||||
12,
|
||||
400,
|
||||
t.fg_muted,
|
||||
)
|
||||
)
|
||||
return box
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Direction A: status board
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_board(snap: WelcomeSnapshot) -> QWidget:
|
||||
"""Everything at a glance: tiles, now running, recent scans, problems, workspaces, actions."""
|
||||
t = tokens()
|
||||
page, lay = _page()
|
||||
lay.addWidget(_experiment_header(snap))
|
||||
|
||||
tiles = QHBoxLayout()
|
||||
tiles.setSpacing(12)
|
||||
for glance in snap.glances:
|
||||
tiles.addWidget(GlanceTile(glance), 1)
|
||||
lay.addLayout(tiles)
|
||||
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(16)
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(16)
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(16)
|
||||
|
||||
now = section_card("Now running", "play_circle", link="Open queue")
|
||||
if snap.running:
|
||||
now.body.addWidget(RunningScanBlock(snap.running))
|
||||
now.body.addSpacing(4)
|
||||
now.body.addWidget(hline())
|
||||
up = QHBoxLayout()
|
||||
up.addWidget(caption(f"Up next · {len(snap.queued)}"))
|
||||
up.addStretch(1)
|
||||
up.addWidget(label("queue empty in ~14 min", 12, 400, t.fg_subtle))
|
||||
now.body.addLayout(up)
|
||||
for i, q in enumerate(snap.queued, start=1):
|
||||
now.body.addWidget(QueueRow(i, q.scan_type, q.summary, q.sample, q.estimate))
|
||||
left.addWidget(now)
|
||||
|
||||
recent = section_card("Recent scans", "history", link="Scan history")
|
||||
recent.body.addWidget(RecentScansTable(snap.recent))
|
||||
left.addWidget(recent)
|
||||
left.addStretch(1)
|
||||
|
||||
attention = section_card("Needs attention", "notifications_active", link="All notifications")
|
||||
worst = "danger" if any(p.tone == "danger" for p in snap.problems) else "warning"
|
||||
count = StatusPill(text=f"{len(snap.problems)}", tone=worst)
|
||||
attention.add_header_widget(count)
|
||||
for prob in snap.problems:
|
||||
attention.body.addWidget(
|
||||
Banner(
|
||||
prob.tone,
|
||||
prob.title,
|
||||
f"{prob.text} · {prob.when}",
|
||||
action_text=prob.action,
|
||||
closable=True,
|
||||
)
|
||||
)
|
||||
right.addWidget(attention)
|
||||
|
||||
cont = section_card("Continue", "dashboard", link="All workspaces")
|
||||
for ws in snap.workspaces[:3]:
|
||||
cont.body.addWidget(WorkspaceRow(ws))
|
||||
right.addWidget(cont)
|
||||
|
||||
quick = section_card("Quick actions", "rocket_launch")
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(8)
|
||||
for i, action in enumerate(snap.actions):
|
||||
grid.addWidget(ActionRow(action), i // 2, i % 2)
|
||||
quick.body.addLayout(grid)
|
||||
right.addWidget(quick)
|
||||
right.addStretch(1)
|
||||
|
||||
body.addLayout(left, 62)
|
||||
body.addLayout(right, 38)
|
||||
lay.addLayout(body, 1)
|
||||
return page
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Direction B: launchpad
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_launchpad(snap: WelcomeSnapshot) -> QWidget:
|
||||
"""A calm start page focused on getting back to work."""
|
||||
t = tokens()
|
||||
outer, outer_lay = _page()
|
||||
outer_lay.setContentsMargins(28, 40, 28, 28)
|
||||
column = QWidget()
|
||||
column.setMaximumWidth(1120)
|
||||
lay = QVBoxLayout(column)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(22)
|
||||
outer_lay.addWidget(column, 1, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
lay.addWidget(_experiment_header(snap, big=True))
|
||||
|
||||
strip = Card(padding=14)
|
||||
srow = QHBoxLayout()
|
||||
srow.setSpacing(18)
|
||||
for i, glance in enumerate(snap.glances[:3]):
|
||||
if i:
|
||||
srow.addWidget(vline())
|
||||
srow.addWidget(StatusChip(glance))
|
||||
srow.addWidget(vline())
|
||||
if snap.running:
|
||||
r = snap.running
|
||||
now = QVBoxLayout()
|
||||
now.setSpacing(4)
|
||||
head = QHBoxLayout()
|
||||
head.setSpacing(8)
|
||||
if r.paused:
|
||||
head.addWidget(StatusPill(text="Paused", tone="warning", icon_name="pause"))
|
||||
else:
|
||||
head.addWidget(StatusPill(text="Running", tone="info", pulse=True))
|
||||
head.addWidget(label(f"#{r.number} {r.scan_type}", 13, 600))
|
||||
left = "paused" if r.paused else f"{r.remaining} left"
|
||||
head.addWidget(label(f"· {left} · {len(snap.queued)} waiting", 12, 400, t.fg_muted))
|
||||
head.addStretch(1)
|
||||
now.addLayout(head)
|
||||
bar = LinearProgress(tone="warning" if r.paused else "info", thickness=6)
|
||||
bar.set_value(r.done / r.total, animate=False)
|
||||
now.addWidget(bar)
|
||||
srow.addLayout(now, 1)
|
||||
srow.addWidget(TextButton("Details", "ghost", "chevron_right", compact=True))
|
||||
strip.body.addLayout(srow)
|
||||
lay.addWidget(strip)
|
||||
|
||||
warn = [p for p in snap.problems if p.tone in ("danger", "warning")]
|
||||
if warn:
|
||||
lay.addWidget(
|
||||
Banner(
|
||||
"danger" if any(p.tone == "danger" for p in warn) else "warning",
|
||||
f"{len(warn)} things need a look",
|
||||
" · ".join(p.title for p in warn),
|
||||
action_text="Review",
|
||||
)
|
||||
)
|
||||
|
||||
head = QHBoxLayout()
|
||||
head.addWidget(label("Continue where you left off", 16, 650))
|
||||
head.addStretch(1)
|
||||
head.addWidget(TextButton("All workspaces", "ghost", compact=True))
|
||||
lay.addLayout(head)
|
||||
tiles = QHBoxLayout()
|
||||
tiles.setSpacing(14)
|
||||
for ws in snap.workspaces[:4]:
|
||||
tiles.addWidget(WorkspaceTile(ws), 1)
|
||||
lay.addLayout(tiles)
|
||||
|
||||
lay.addWidget(label("Start something", 16, 650))
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(14)
|
||||
for action in snap.actions:
|
||||
tile = ActionTile(action)
|
||||
tile.setMinimumHeight(132)
|
||||
actions.addWidget(tile, 1)
|
||||
lay.addLayout(actions)
|
||||
|
||||
tip = Card(padding=14, surface="sunken")
|
||||
trow = QHBoxLayout()
|
||||
trow.setSpacing(12)
|
||||
trow.addWidget(icon_badge("lightbulb", "warning", 36, 20), 0, Qt.AlignmentFlag.AlignTop)
|
||||
tcol = QVBoxLayout()
|
||||
tcol.setSpacing(2)
|
||||
tcol.addWidget(label(f"Tip · {snap.tip[0]}", 13, 600))
|
||||
tcol.addWidget(label(snap.tip[1], 12, 400, t.fg_muted, wrap=True))
|
||||
trow.addLayout(tcol, 1)
|
||||
trow.addWidget(TextButton("Next tip", "ghost", compact=True), 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
trow.addWidget(
|
||||
TextButton("Don't show tips", "ghost", compact=True), 0, Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
tip.body.addLayout(trow)
|
||||
lay.addWidget(tip)
|
||||
return outer
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Direction C: activity feed
|
||||
# --------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_feed(snap: WelcomeSnapshot) -> QWidget:
|
||||
"""Shift handover: state now on the left, what happened since last visit on the right."""
|
||||
t = tokens()
|
||||
page, lay = _page()
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(18)
|
||||
lay.addLayout(body, 1)
|
||||
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(14)
|
||||
side = QWidget()
|
||||
side.setFixedWidth(380)
|
||||
side.setLayout(left)
|
||||
|
||||
exp = Card(padding=16)
|
||||
exp.body.addWidget(caption(snap.experiment.beamline))
|
||||
exp.body.addWidget(label(snap.experiment.title, 16, 650, wrap=True))
|
||||
prow = QHBoxLayout()
|
||||
prow.setSpacing(6)
|
||||
prow.addWidget(StatusPill(text=snap.experiment.account, tone="primary", icon_name="badge"))
|
||||
prow.addWidget(StatusPill(text=snap.experiment.day, tone="neutral", outlined=True))
|
||||
prow.addStretch(1)
|
||||
exp.body.addLayout(prow)
|
||||
left.addWidget(exp)
|
||||
|
||||
state = section_card("Right now", "sensors")
|
||||
for i, glance in enumerate(snap.glances):
|
||||
if i:
|
||||
state.body.addWidget(hline())
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
row.addWidget(icon_label(glance.icon, t.fg_muted, 18))
|
||||
row.addWidget(label(glance.label, 13, 500), 1)
|
||||
row.addWidget(StatusPill(text=glance.value, tone=glance.tone))
|
||||
state.body.addLayout(row)
|
||||
if glance.tone in ("warning", "danger"):
|
||||
state.body.addWidget(label(f" {glance.detail}", 12, 400, t.tone_text(glance.tone)))
|
||||
left.addWidget(state)
|
||||
|
||||
now = section_card("Now running", "play_circle")
|
||||
if snap.running:
|
||||
now.body.addWidget(RunningScanBlock(snap.running, compact=True))
|
||||
now.body.addWidget(hline())
|
||||
for i, q in enumerate(snap.queued, start=1):
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(label(f"{i}", 12, 600, t.fg_subtle))
|
||||
row.addWidget(label(q.scan_type, 13, 600))
|
||||
row.addWidget(label(q.estimate, 12, 400, t.fg_muted))
|
||||
row.addStretch(1)
|
||||
if q.sample:
|
||||
row.addWidget(StatusPill(text=q.sample, tone="neutral", outlined=True))
|
||||
now.body.addLayout(row)
|
||||
left.addWidget(now)
|
||||
left.addStretch(1)
|
||||
body.addWidget(side)
|
||||
|
||||
feed = Card(padding=18)
|
||||
head = QHBoxLayout()
|
||||
hcol = QVBoxLayout()
|
||||
hcol.setSpacing(2)
|
||||
hcol.addWidget(label("Since you were last here", 20, 650))
|
||||
hcol.addWidget(
|
||||
label(
|
||||
f"You left at {snap.away_since}, 4 h 10 min ago. " "Here is what happened.",
|
||||
13,
|
||||
400,
|
||||
t.fg_muted,
|
||||
)
|
||||
)
|
||||
head.addLayout(hcol, 1)
|
||||
head.addWidget(
|
||||
TextButton("Mark all seen", "neutral", "done_all", compact=True),
|
||||
0,
|
||||
Qt.AlignmentFlag.AlignTop,
|
||||
)
|
||||
feed.body.addLayout(head)
|
||||
chips = QHBoxLayout()
|
||||
chips.setSpacing(8)
|
||||
for icon, tone, text in snap.away_summary:
|
||||
chips.addWidget(SummaryChip(icon, tone, text))
|
||||
chips.addStretch(1)
|
||||
feed.body.addLayout(chips)
|
||||
feed.body.addSpacing(4)
|
||||
|
||||
kinds = {"scan": 0, "problem": 0, "interlock": 0, "config": 0, "note": 0, "session": 0}
|
||||
for ev in snap.events:
|
||||
kinds[ev.kind] += 1
|
||||
problems = kinds["problem"] + kinds["interlock"]
|
||||
changes = kinds["config"] + kinds["note"] + kinds["session"]
|
||||
filt = SegmentedControl(
|
||||
[
|
||||
{"text": "All", "count": len(snap.events)},
|
||||
{"text": "Scans", "count": kinds["scan"]},
|
||||
{"text": "Problems", "count": problems},
|
||||
{"text": "Changes and notes", "count": changes},
|
||||
],
|
||||
compact=True,
|
||||
)
|
||||
frow = QHBoxLayout()
|
||||
frow.addWidget(filt)
|
||||
frow.addStretch(1)
|
||||
frow.addWidget(TextButton("Open in logbook", "ghost", "open_in_new", compact=True))
|
||||
feed.body.addLayout(frow)
|
||||
feed.body.addWidget(hline())
|
||||
|
||||
groups = [("Last hour", snap.events[:3]), ("Earlier this evening", snap.events[3:])]
|
||||
for title, events in groups:
|
||||
feed.body.addWidget(caption(title))
|
||||
for i, ev in enumerate(events):
|
||||
feed.body.addWidget(TimelineRow(ev, last=i == len(events) - 1))
|
||||
feed.body.addStretch(1)
|
||||
body.addWidget(feed, 1)
|
||||
return page
|
||||
|
||||
|
||||
BUILDERS = {"board": build_board, "launchpad": build_launchpad, "feed": build_feed}
|
||||
|
||||
|
||||
class WelcomeView(ViewBase):
|
||||
"""Home view of the main app that summarises the current state of the beamline.
|
||||
|
||||
Args:
|
||||
parent(QWidget | None): Parent widget.
|
||||
direction(str): First design direction to show: ``board``, ``launchpad`` or ``feed``.
|
||||
snapshot(WelcomeSnapshot | None): Data to show; defaults to the demo shift.
|
||||
show_switcher(bool): Show the direction switcher used for the design discussion.
|
||||
"""
|
||||
|
||||
RPC = False
|
||||
PLUGIN = False
|
||||
|
||||
navigate = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
direction: str = "board",
|
||||
snapshot: WelcomeSnapshot | None = None,
|
||||
show_switcher: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(parent=parent, view_id="welcome", title="Home", **kwargs)
|
||||
self.snapshot = snapshot or demo_snapshot()
|
||||
self._direction = direction if direction in BUILDERS else "board"
|
||||
self._show_switcher = show_switcher
|
||||
self._root = QWidget(self)
|
||||
self._root_lay = QVBoxLayout(self._root)
|
||||
self._root_lay.setContentsMargins(0, 0, 0, 0)
|
||||
self._root_lay.setSpacing(0)
|
||||
self.set_content(self._root)
|
||||
self._stack: QStackedWidget | None = None
|
||||
self._rebuild()
|
||||
theme = app_theme()
|
||||
if theme is not None:
|
||||
theme.theme_changed.connect(self._rebuild)
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
"""The design direction on screen."""
|
||||
return self._direction
|
||||
|
||||
def set_direction(self, direction: str) -> None:
|
||||
"""Switch between the design directions."""
|
||||
if direction not in BUILDERS:
|
||||
raise ValueError(f"Unknown direction {direction!r}; use one of {list(BUILDERS)}")
|
||||
self._direction = direction
|
||||
self._rebuild()
|
||||
|
||||
def set_snapshot(self, snapshot: WelcomeSnapshot) -> None:
|
||||
"""Show different data, e.g. another demo scenario."""
|
||||
self.snapshot = snapshot
|
||||
self._rebuild()
|
||||
|
||||
def set_switcher_visible(self, visible: bool) -> None:
|
||||
"""Show or hide the direction switcher, e.g. for screenshots."""
|
||||
self._show_switcher = visible
|
||||
self._rebuild()
|
||||
|
||||
def _rebuild(self, *_args) -> None:
|
||||
while self._root_lay.count():
|
||||
item = self._root_lay.takeAt(0)
|
||||
if item.widget() is not None:
|
||||
item.widget().deleteLater()
|
||||
if self._show_switcher:
|
||||
self._root_lay.addWidget(self._switcher_bar())
|
||||
page = BUILDERS[self._direction](self.snapshot)
|
||||
self._root_lay.addWidget(_scroll(page), 1)
|
||||
|
||||
def _switcher_bar(self) -> QWidget:
|
||||
t = tokens()
|
||||
bar = QWidget()
|
||||
bar.setObjectName("welcomeSwitcher")
|
||||
bar.setStyleSheet(
|
||||
f"QWidget#welcomeSwitcher {{ background: {t.card.name()};"
|
||||
f" border-bottom: 1px solid {t.border.name()}; }}"
|
||||
)
|
||||
row = QHBoxLayout(bar)
|
||||
row.setContentsMargins(16, 6, 16, 6)
|
||||
row.addWidget(label("Welcome screen mockup", 12, 600, t.fg_muted))
|
||||
row.addStretch(1)
|
||||
seg = SegmentedControl([title for _, title in DIRECTIONS], compact=True)
|
||||
keys = [key for key, _ in DIRECTIONS]
|
||||
seg.set_current_index(keys.index(self._direction))
|
||||
seg.activated.connect(lambda i: self.set_direction(keys[i]))
|
||||
row.addWidget(seg)
|
||||
return bar
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import sys
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
|
||||
qapp = QApplication(sys.argv)
|
||||
apply_theme("dark")
|
||||
view = WelcomeView(direction=sys.argv[1] if len(sys.argv) > 1 else "board")
|
||||
view.resize(1540, 900)
|
||||
view.show()
|
||||
sys.exit(qapp.exec())
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Building blocks of the welcome view, drawn with the BEC UI kit tokens.
|
||||
|
||||
The welcome view rebuilds its pages on a theme switch, so these widgets read
|
||||
:class:`ThemeTokens` once when they are created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QRectF, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.applications.views.welcome_view.welcome_data import (
|
||||
Event,
|
||||
Glance,
|
||||
QuickAction,
|
||||
RecentScan,
|
||||
RunningScan,
|
||||
Workspace,
|
||||
)
|
||||
from bec_widgets.utils.quick.tokens import ThemeTokens
|
||||
from bec_widgets.utils.ux_kit import Card, IconButton, LinearProgress, StatusPill, TextButton
|
||||
|
||||
DOCK_ICONS = {
|
||||
"Waveform": "show_chart",
|
||||
"Image": "image",
|
||||
"Heatmap": "grid_on",
|
||||
"PositionerBox": "open_with",
|
||||
"Queue": "playlist_play",
|
||||
"Ring": "donut_large",
|
||||
"ScanControl": "tune",
|
||||
}
|
||||
|
||||
|
||||
def tokens() -> ThemeTokens:
|
||||
"""Current theme tokens."""
|
||||
return ThemeTokens.current()
|
||||
|
||||
|
||||
def label(
|
||||
text: str,
|
||||
size: int = 13,
|
||||
weight: int = 400,
|
||||
color: QColor | None = None,
|
||||
mono: bool = False,
|
||||
wrap: bool = False,
|
||||
spacing: float = 0.0,
|
||||
) -> QLabel:
|
||||
"""A transparent label with an explicit pixel size, weight and colour."""
|
||||
t = tokens()
|
||||
lab = QLabel(text)
|
||||
family = f"font-family: '{t.mono_family}';" if mono else ""
|
||||
extra = f"letter-spacing: {spacing}px;" if spacing else ""
|
||||
lab.setStyleSheet(
|
||||
f"background: transparent; color: {(color or t.fg).name()}; font-size: {size}px;"
|
||||
f" font-weight: {weight}; {family} {extra}"
|
||||
)
|
||||
lab.setWordWrap(wrap)
|
||||
lab.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
|
||||
return lab
|
||||
|
||||
|
||||
def caption(text: str) -> QLabel:
|
||||
"""Small uppercase section caption."""
|
||||
return label(text.upper(), 11, 700, tokens().fg_muted, spacing=0.8)
|
||||
|
||||
|
||||
def icon_label(name: str, color: QColor, size: int = 18, filled: bool = False) -> QLabel:
|
||||
"""A Material icon as a label."""
|
||||
lab = QLabel()
|
||||
lab.setStyleSheet("background: transparent;")
|
||||
pm = material_icon(
|
||||
name, size=(size * 2, size * 2), color=color, filled=filled, convert_to_pixmap=True
|
||||
)
|
||||
pm.setDevicePixelRatio(2.0)
|
||||
lab.setPixmap(pm)
|
||||
lab.setFixedSize(size, size)
|
||||
return lab
|
||||
|
||||
|
||||
def icon_badge(name: str, tone: str, side: int = 36, icon: int = 20) -> QLabel:
|
||||
"""Icon centred in a tinted rounded square."""
|
||||
t = tokens()
|
||||
lab = icon_label(name, t.tone_text(tone) if tone != "neutral" else t.fg_muted, icon)
|
||||
lab.setFixedSize(side, side)
|
||||
lab.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
tint = t.tone_tint(tone) if tone != "neutral" else t.hover
|
||||
lab.setStyleSheet(f"background: {tint.name()}; border-radius: {side // 4}px;")
|
||||
return lab
|
||||
|
||||
|
||||
def keycap(text: str) -> QLabel:
|
||||
"""Keyboard shortcut hint."""
|
||||
t = tokens()
|
||||
lab = label(text, 11, 500, t.fg_muted)
|
||||
lab.setStyleSheet(
|
||||
lab.styleSheet() + f" border: 1px solid {t.border.name()}; border-radius: 4px;"
|
||||
f" padding: 1px 5px; background: {t.sunken.name()};"
|
||||
)
|
||||
return lab
|
||||
|
||||
|
||||
def hline() -> QFrame:
|
||||
"""Hairline separator."""
|
||||
line = QFrame()
|
||||
line.setFixedHeight(1)
|
||||
line.setStyleSheet(f"background: {tokens().separator.name()}; border: none;")
|
||||
return line
|
||||
|
||||
|
||||
def vline() -> QFrame:
|
||||
"""Vertical hairline separator."""
|
||||
line = QFrame()
|
||||
line.setFixedWidth(1)
|
||||
line.setStyleSheet(f"background: {tokens().separator.name()}; border: none;")
|
||||
return line
|
||||
|
||||
|
||||
class ClickFrame(QFrame):
|
||||
"""Frame with a hover fill that emits :attr:`clicked`."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, radius: int = 8, bordered: bool = False):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
self.setObjectName("wClick")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
border = f"border: 1px solid {t.border.name()};" if bordered else "border: none;"
|
||||
bg = t.card.name() if bordered else "transparent"
|
||||
self.setStyleSheet(
|
||||
f"QFrame#wClick {{ background: {bg}; {border} border-radius: {radius}px; }}"
|
||||
f"QFrame#wClick:hover {{ background: {t.hover.name()}; }}"
|
||||
)
|
||||
|
||||
def mouseReleaseEvent(self, event): # pylint: disable=invalid-name
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class GlanceTile(ClickFrame):
|
||||
"""Subsystem tile: icon, label, big value and a detail line, with a tone accent."""
|
||||
|
||||
def __init__(self, glance: Glance, parent: QWidget | None = None):
|
||||
super().__init__(parent, radius=10, bordered=True)
|
||||
t = tokens()
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(14, 12, 14, 12)
|
||||
lay.setSpacing(12)
|
||||
lay.addWidget(icon_badge(glance.icon, glance.tone, 40, 22), 0, Qt.AlignmentFlag.AlignTop)
|
||||
col = QVBoxLayout()
|
||||
col.setSpacing(1)
|
||||
col.addWidget(label(glance.label, 12, 500, t.fg_muted))
|
||||
col.addWidget(label(glance.value, 18, 650))
|
||||
detail_color = (
|
||||
t.tone_text(glance.tone) if glance.tone in ("warning", "danger") else t.fg_subtle
|
||||
)
|
||||
col.addWidget(label(glance.detail, 12, 400, detail_color))
|
||||
lay.addLayout(col, 1)
|
||||
lay.addWidget(icon_label("chevron_right", t.fg_subtle, 18), 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
|
||||
class StatusChip(QWidget):
|
||||
"""Compact glance: tone dot, label and value in one line."""
|
||||
|
||||
def __init__(self, glance: Glance, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(8)
|
||||
lay.addWidget(icon_label(glance.icon, t.tone_text(glance.tone), 18))
|
||||
col = QVBoxLayout()
|
||||
col.setSpacing(0)
|
||||
col.addWidget(label(glance.label, 11, 500, t.fg_muted))
|
||||
col.addWidget(label(glance.value, 13, 600))
|
||||
lay.addLayout(col)
|
||||
|
||||
|
||||
class RunningScanBlock(QWidget):
|
||||
"""Running scan with progress and the main controls."""
|
||||
|
||||
def __init__(self, scan: RunningScan, compact: bool = False, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(8)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(10)
|
||||
if scan.paused:
|
||||
top.addWidget(StatusPill(text="Paused", tone="warning", icon_name="pause"))
|
||||
else:
|
||||
top.addWidget(StatusPill(text="Running", tone="info", pulse=True))
|
||||
top.addWidget(label(f"#{scan.number}", 14 if compact else 16, 650, t.fg_muted, mono=True))
|
||||
top.addWidget(label(scan.scan_type, 14 if compact else 16, 650))
|
||||
top.addStretch(1)
|
||||
if not compact:
|
||||
top.addWidget(TextButton("Live plot", "neutral", "show_chart", compact=True))
|
||||
top.addWidget(self._pause_button(scan))
|
||||
top.addWidget(TextButton("Abort", "dangerOutline", "stop", compact=True))
|
||||
lay.addLayout(top)
|
||||
meta = f"{scan.summary} · sample {scan.sample}"
|
||||
lay.addWidget(label(meta, 12, 400, t.fg_muted, wrap=compact))
|
||||
bar = LinearProgress(
|
||||
tone="warning" if scan.paused else "info", thickness=8 if not compact else 6
|
||||
)
|
||||
bar.set_value(scan.done / scan.total, animate=False)
|
||||
lay.addWidget(bar)
|
||||
foot = QHBoxLayout()
|
||||
pct = round(100 * scan.done / scan.total)
|
||||
foot.addWidget(label(f"{scan.done} of {scan.total} points · {pct} %", 12, 500))
|
||||
foot.addStretch(1)
|
||||
if scan.paused:
|
||||
timing = "paused" if compact else "waiting for the interlock"
|
||||
else:
|
||||
timing = f"{scan.remaining} left"
|
||||
foot.addWidget(label(f"{scan.elapsed} elapsed · {timing}", 12, 400, t.fg_muted))
|
||||
lay.addLayout(foot)
|
||||
if compact:
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(6)
|
||||
row.addWidget(TextButton("Live plot", "neutral", "show_chart", compact=True))
|
||||
row.addWidget(self._pause_button(scan))
|
||||
row.addStretch(1)
|
||||
row.addWidget(TextButton("Abort", "dangerOutline", "stop", compact=True))
|
||||
lay.addLayout(row)
|
||||
|
||||
@staticmethod
|
||||
def _pause_button(scan: RunningScan) -> TextButton:
|
||||
if scan.paused:
|
||||
return TextButton("Resume", "neutral", "play_arrow", compact=True)
|
||||
return TextButton("Pause", "neutral", "pause", compact=True)
|
||||
|
||||
|
||||
class QueueRow(QWidget):
|
||||
"""One waiting scan."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
index: int,
|
||||
scan_type: str,
|
||||
summary: str,
|
||||
sample: str,
|
||||
estimate: str,
|
||||
parent: QWidget | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 2, 0, 2)
|
||||
lay.setSpacing(10)
|
||||
num = label(str(index), 12, 600, t.fg_subtle)
|
||||
num.setFixedWidth(14)
|
||||
lay.addWidget(num)
|
||||
lay.addWidget(StatusPill(text="Queued", tone="subtle", icon_name="schedule"))
|
||||
lay.addWidget(label(scan_type, 13, 600))
|
||||
lay.addWidget(label(summary, 12, 400, t.fg_muted), 1)
|
||||
if sample:
|
||||
lay.addWidget(StatusPill(text=sample, tone="neutral", outlined=True))
|
||||
lay.addWidget(label(estimate, 12, 400, t.fg_subtle))
|
||||
|
||||
|
||||
class RecentScansTable(QWidget):
|
||||
"""Recent scans with status and quick links."""
|
||||
|
||||
def __init__(self, scans: list[RecentScan], parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
grid = QGridLayout(self)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(14)
|
||||
grid.setVerticalSpacing(0)
|
||||
heads = ["#", "Scan", "", "Sample", "Status", "Time", "Finished", ""]
|
||||
for col, head in enumerate(heads):
|
||||
grid.addWidget(label(head, 11, 600, t.fg_subtle), 0, col)
|
||||
for row, scan in enumerate(scans, start=1):
|
||||
r = row * 2
|
||||
grid.addWidget(hline(), r - 1, 0, 1, len(heads))
|
||||
grid.addWidget(label(str(scan.number), 13, 600, t.fg_muted, mono=True), r, 0)
|
||||
grid.addWidget(label(scan.scan_type, 13, 600), r, 1)
|
||||
grid.addWidget(label(scan.summary, 12, 400, t.fg_muted), r, 2)
|
||||
grid.addWidget(label(scan.sample, 12, 400), r, 3)
|
||||
grid.addWidget(StatusPill(text=scan.status, tone=scan.tone), r, 4)
|
||||
grid.addWidget(label(scan.duration, 12, 400, t.fg_muted, mono=True), r, 5)
|
||||
grid.addWidget(label(scan.finished, 12, 400, t.fg_muted, mono=True), r, 6)
|
||||
links = QHBoxLayout()
|
||||
links.setSpacing(0)
|
||||
links.addWidget(IconButton("show_chart", "Plot in current workspace", compact=True))
|
||||
links.addWidget(IconButton("replay", "Re-run in scan control", compact=True))
|
||||
links.addWidget(IconButton("more_horiz", "More", compact=True))
|
||||
holder = QWidget()
|
||||
holder.setStyleSheet("background: transparent;")
|
||||
holder.setLayout(links)
|
||||
grid.addWidget(holder, r, 7)
|
||||
grid.setRowMinimumHeight(r, 38)
|
||||
grid.setColumnStretch(2, 1)
|
||||
grid.setRowStretch(len(scans) * 2 + 1, 1)
|
||||
|
||||
|
||||
class WorkspaceThumb(QWidget):
|
||||
"""Schematic preview of a dock layout: one large dock on the left, the rest stacked."""
|
||||
|
||||
def __init__(self, docks: list[str], parent: QWidget | None = None, size=(112, 70)):
|
||||
super().__init__(parent)
|
||||
self.docks = docks
|
||||
self.setFixedSize(*size)
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
t = tokens()
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
r = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
p.setPen(QPen(t.border, 1))
|
||||
p.setBrush(t.sunken)
|
||||
p.drawRoundedRect(r, 6, 6)
|
||||
inner = r.adjusted(4, 4, -4, -4)
|
||||
rects = []
|
||||
if len(self.docks) == 1:
|
||||
rects = [inner]
|
||||
else:
|
||||
left_w = inner.width() * 0.6
|
||||
rects.append(QRectF(inner.left(), inner.top(), left_w - 2, inner.height()))
|
||||
rest = self.docks[1:]
|
||||
h = (inner.height() - 3 * (len(rest) - 1)) / len(rest)
|
||||
for i in range(len(rest)):
|
||||
rects.append(
|
||||
QRectF(
|
||||
inner.left() + left_w + 1,
|
||||
inner.top() + i * (h + 3),
|
||||
inner.width() - left_w - 1,
|
||||
h,
|
||||
)
|
||||
)
|
||||
for dock, rect in zip(self.docks, rects):
|
||||
p.setPen(QPen(t.border, 1))
|
||||
p.setBrush(t.card)
|
||||
p.drawRoundedRect(rect, 3, 3)
|
||||
if dock == "Waveform" and rect.height() > 30:
|
||||
path = QPainterPath()
|
||||
pts = [0.85, 0.82, 0.84, 0.78, 0.6, 0.25, 0.2, 0.5, 0.8, 0.83]
|
||||
for i, v in enumerate(pts):
|
||||
x = rect.left() + 6 + i * (rect.width() - 12) / (len(pts) - 1)
|
||||
y = rect.top() + 6 + v * (rect.height() - 12)
|
||||
path.moveTo(x, y) if i == 0 else path.lineTo(x, y)
|
||||
p.setPen(QPen(t.primary, 1.5))
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.drawPath(path)
|
||||
else:
|
||||
side = min(14, int(rect.height()) - 4)
|
||||
if side >= 8:
|
||||
pm = material_icon(
|
||||
DOCK_ICONS.get(dock, "widgets"),
|
||||
size=(side * 2, side * 2),
|
||||
color=t.fg_subtle,
|
||||
convert_to_pixmap=True,
|
||||
)
|
||||
p.drawPixmap(
|
||||
QRectF(
|
||||
rect.center().x() - side / 2, rect.center().y() - side / 2, side, side
|
||||
).toRect(),
|
||||
pm,
|
||||
)
|
||||
p.end()
|
||||
|
||||
|
||||
class WorkspaceRow(ClickFrame):
|
||||
"""Recent workspace as a list row."""
|
||||
|
||||
def __init__(self, ws: Workspace, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(6, 6, 8, 6)
|
||||
lay.setSpacing(12)
|
||||
lay.addWidget(WorkspaceThumb(ws.docks, size=(64, 40)))
|
||||
col = QVBoxLayout()
|
||||
col.setSpacing(1)
|
||||
col.addWidget(label(ws.name, 13, 600))
|
||||
col.addWidget(label(f"{len(ws.docks)} docks · {ws.opened}", 12, 400, t.fg_muted))
|
||||
lay.addLayout(col, 1)
|
||||
if ws.current:
|
||||
lay.addWidget(StatusPill(text="Open", tone="success"))
|
||||
else:
|
||||
lay.addWidget(icon_label("open_in_new", t.fg_subtle, 16))
|
||||
|
||||
|
||||
class WorkspaceTile(ClickFrame):
|
||||
"""Recent workspace as a large tile with a preview."""
|
||||
|
||||
def __init__(self, ws: Workspace, parent: QWidget | None = None):
|
||||
super().__init__(parent, radius=10, bordered=True)
|
||||
t = tokens()
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(10, 10, 10, 10)
|
||||
lay.setSpacing(8)
|
||||
lay.addWidget(WorkspaceThumb(ws.docks, size=(220, 120)), 0, Qt.AlignmentFlag.AlignHCenter)
|
||||
row = QHBoxLayout()
|
||||
col = QVBoxLayout()
|
||||
col.setSpacing(1)
|
||||
col.addWidget(label(ws.name, 14, 600))
|
||||
col.addWidget(label(f"{len(ws.docks)} docks · {ws.opened}", 12, 400, t.fg_muted))
|
||||
row.addLayout(col, 1)
|
||||
if ws.current:
|
||||
row.addWidget(StatusPill(text="Open", tone="success"), 0, Qt.AlignmentFlag.AlignTop)
|
||||
lay.addLayout(row)
|
||||
|
||||
|
||||
class ActionRow(ClickFrame):
|
||||
"""Quick action as a compact row."""
|
||||
|
||||
def __init__(self, action: QuickAction, parent: QWidget | None = None):
|
||||
super().__init__(parent, radius=8, bordered=True)
|
||||
self.setToolTip(action.text)
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(8, 6, 10, 6)
|
||||
lay.setSpacing(10)
|
||||
lay.addWidget(icon_badge(action.icon, "primary", 28, 16))
|
||||
lay.addWidget(label(action.title, 13, 600), 1)
|
||||
if action.shortcut:
|
||||
lay.addWidget(keycap(action.shortcut))
|
||||
|
||||
|
||||
class ActionTile(ClickFrame):
|
||||
"""Quick action as a large tile."""
|
||||
|
||||
def __init__(self, action: QuickAction, parent: QWidget | None = None):
|
||||
super().__init__(parent, radius=10, bordered=True)
|
||||
t = tokens()
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(16, 16, 16, 14)
|
||||
lay.setSpacing(6)
|
||||
top = QHBoxLayout()
|
||||
top.addWidget(icon_badge(action.icon, "primary", 40, 22))
|
||||
top.addStretch(1)
|
||||
if action.shortcut:
|
||||
top.addWidget(keycap(action.shortcut), 0, Qt.AlignmentFlag.AlignTop)
|
||||
lay.addLayout(top)
|
||||
lay.addSpacing(4)
|
||||
lay.addWidget(label(action.title, 14, 600))
|
||||
lay.addWidget(label(action.text, 12, 400, t.fg_muted, wrap=True))
|
||||
lay.addStretch(1)
|
||||
|
||||
|
||||
class TimelineRow(QWidget):
|
||||
"""One activity feed entry: time, icon on a rail, text, optional action."""
|
||||
|
||||
def __init__(self, event: Event, last: bool = False, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.last = last
|
||||
self.tone = event.tone
|
||||
t = tokens()
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(12)
|
||||
time = label(event.time, 12, 500, t.fg_muted, mono=True)
|
||||
time.setFixedWidth(44)
|
||||
time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
|
||||
time.setContentsMargins(0, 8, 0, 0)
|
||||
lay.addWidget(time, 0, Qt.AlignmentFlag.AlignTop)
|
||||
self.rail_x = 44 + 12 + 16
|
||||
badge = icon_badge(event.icon, event.tone, 32, 18)
|
||||
lay.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop)
|
||||
col = QVBoxLayout()
|
||||
col.setContentsMargins(0, 6, 0, 14)
|
||||
col.setSpacing(2)
|
||||
col.addWidget(label(event.title, 13, 600))
|
||||
if event.text:
|
||||
col.addWidget(label(event.text, 12, 400, t.fg_muted, wrap=True))
|
||||
lay.addLayout(col, 1)
|
||||
if event.action:
|
||||
btn = TextButton(event.action, "ghost", compact=True)
|
||||
lay.addWidget(btn, 0, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
def paintEvent(self, _event): # pylint: disable=invalid-name
|
||||
if self.last:
|
||||
return
|
||||
p = QPainter(self)
|
||||
p.setPen(QPen(tokens().separator, 2))
|
||||
p.drawLine(self.rail_x, 34, self.rail_x, self.height())
|
||||
p.end()
|
||||
|
||||
|
||||
class SummaryChip(QWidget):
|
||||
"""Icon and short text for the 'since you were away' summary."""
|
||||
|
||||
def __init__(self, icon: str, tone: str, text: str, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
t = tokens()
|
||||
self.setObjectName("wChip")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.setStyleSheet(
|
||||
f"QWidget#wChip {{ background: {t.tone_tint(tone).name()};" f" border-radius: 14px; }}"
|
||||
)
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(10, 5, 12, 5)
|
||||
lay.setSpacing(6)
|
||||
lay.addWidget(icon_label(icon, t.tone_text(tone), 16))
|
||||
lay.addWidget(label(text, 12, 600, t.tone_text(tone)))
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
|
||||
|
||||
def section_card(title: str, icon: str = "", subtitle: str = "", link: str = "") -> Card:
|
||||
"""A titled card, optionally with a 'View all' style link in the header."""
|
||||
card = Card(title, icon_name=icon, padding=16)
|
||||
if subtitle:
|
||||
card.set_title(title, subtitle)
|
||||
if link:
|
||||
card.add_header_widget(TextButton(link, "ghost", compact=True))
|
||||
return card
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
|
||||
from bec_widgets.applications.views.welcome_view.welcome_data import demo_snapshot
|
||||
from bec_widgets.applications.views.welcome_view.welcome_view import (
|
||||
DIRECTIONS,
|
||||
WelcomeView,
|
||||
welcome_view_requested,
|
||||
)
|
||||
from bec_widgets.tests.utils import create_widget
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[("", None), ("0", None), ("off", None), ("board", "board"), ("FEED", "feed"), ("1", "board")],
|
||||
)
|
||||
def test_welcome_view_requested(monkeypatch, value, expected):
|
||||
monkeypatch.setenv("BEC_WELCOME_VIEW", value)
|
||||
assert welcome_view_requested() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("direction", [key for key, _ in DIRECTIONS])
|
||||
def test_welcome_view_builds_every_direction(qtbot, mocked_client, direction):
|
||||
view = create_widget(qtbot, WelcomeView, client=mocked_client, direction=direction)
|
||||
assert view.direction == direction
|
||||
view.set_switcher_visible(False)
|
||||
view.set_direction("board")
|
||||
assert view.direction == "board"
|
||||
|
||||
|
||||
def test_welcome_view_rejects_unknown_direction(qtbot, mocked_client):
|
||||
view = create_widget(qtbot, WelcomeView, client=mocked_client)
|
||||
with pytest.raises(ValueError):
|
||||
view.set_direction("nope")
|
||||
|
||||
|
||||
def test_demo_snapshot_is_consistent():
|
||||
snap = demo_snapshot()
|
||||
assert snap.running is not None and 0 < snap.running.done < snap.running.total
|
||||
assert len(snap.glances) == 4
|
||||
assert all(
|
||||
ev.kind in {"scan", "problem", "interlock", "config", "note", "session"}
|
||||
for ev in snap.events
|
||||
)
|
||||
Reference in new issue
Block a user