diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..67aae108 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -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 diff --git a/bec_widgets/applications/views/welcome_view/__init__.py b/bec_widgets/applications/views/welcome_view/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/applications/views/welcome_view/welcome_data.py b/bec_widgets/applications/views/welcome_view/welcome_data.py new file mode 100644 index 00000000..aa0d05f4 --- /dev/null +++ b/bec_widgets/applications/views/welcome_view/welcome_data.py @@ -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 diff --git a/bec_widgets/applications/views/welcome_view/welcome_view.py b/bec_widgets/applications/views/welcome_view/welcome_view.py new file mode 100644 index 00000000..e4fcf1f8 --- /dev/null +++ b/bec_widgets/applications/views/welcome_view/welcome_view.py @@ -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()) diff --git a/bec_widgets/applications/views/welcome_view/welcome_widgets.py b/bec_widgets/applications/views/welcome_view/welcome_widgets.py new file mode 100644 index 00000000..780c304c --- /dev/null +++ b/bec_widgets/applications/views/welcome_view/welcome_widgets.py @@ -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 diff --git a/tests/unit_tests/test_welcome_view.py b/tests/unit_tests/test_welcome_view.py new file mode 100644 index 00000000..b4d2ddb3 --- /dev/null +++ b/tests/unit_tests/test_welcome_view.py @@ -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 + )