feat(welcome_view): mock up three welcome screen directions as an opt-in Home view in bec-app

This commit is contained in:
Claude committed 2026-10-09 22:11:35 +00:00
1 parent f618e79757
commit 4a387761ae
6 files changed
+1576 -1

No files matched your search

+16 -1
View File
@@ -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
+43
View File
@@ -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
)