"""Real Qt widgets the thirty Home-screen variants are built from.
Everything here is a genuine widget — the ones that already exist in
spaCR (``HTile``, ``Card``, ``Section``, ``Divider``, ``UsageBar``,
``ElidingLabel``, the real ``Sidebar``) are imported and used as-is; the
ones a variant proposes but the app does not have yet (a recent-runs
strip, a resume banner, a guided quick-start, a project status bar, a
what's-new panel, a big illustrated tile) are built here as minimal but
real widgets, so a layout that wins the review is known-buildable.
No mockups. No painted screenshots.
"""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QFont, QFontMetrics, QTextLayout
from PySide6.QtWidgets import (
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
import common
from common import MOCK, Ctx, blurb_of, name_of
# ---------------------------------------------------------------------------
# Text that never clips
# ---------------------------------------------------------------------------
[docs]
def elide_to_lines(text: str, font: QFont, width: int, lines: int) -> str:
"""Shorten ``text`` so it lays out in at most ``lines`` at ``width`` px.
Uses :class:`QTextLayout` — the same line breaker QLabel uses — so
the answer is exact rather than a character-count guess. Text
clipping is the exact defect the home-screen rework was raised to
fix, so every wrapped blurb in these variants goes through here.
"""
if width <= 0 or lines <= 0 or not text:
return text
layout = QTextLayout(text, font)
layout.beginLayout()
starts: List[int] = []
while True:
line = layout.createLine()
if not line.isValid():
break
line.setLineWidth(width)
starts.append(line.textStart())
if len(starts) > lines:
break
layout.endLayout()
if len(starts) <= lines:
return text
cut = starts[lines - 1]
fm = QFontMetrics(font)
return text[:cut] + fm.elidedText(text[cut:], Qt.ElideRight, width)
[docs]
def line_count(text: str, font: QFont, width: int) -> int:
"""How many lines ``text`` occupies when wrapped at ``width`` px."""
if width <= 0 or not text:
return 1
layout = QTextLayout(text, font)
layout.beginLayout()
n = 0
while True:
line = layout.createLine()
if not line.isValid():
break
line.setLineWidth(width)
n += 1
layout.endLayout()
return max(1, n)
[docs]
def wrapped(ctx: Ctx, text: str, width: int, lines: int, *,
color: Optional[str] = None, size: int = 12,
weight: int = 300, reserve: bool = False) -> QLabel:
"""A word-wrapped label with a guaranteed line budget.
``lines`` is a *ceiling*: longer text is elided to fit it, and the
label is then sized to the lines it actually uses, so a one-line
string in a three-line budget does not leave a hole. Pass
``reserve=True`` where a fixed height matters (grid cells that must
line up).
"""
lbl = QLabel()
lbl.setStyleSheet(
f"color: {color or ctx.P['fg_muted']}; font-size: {size}px;"
f"font-weight: {weight}; background: transparent;")
lbl.setWordWrap(True)
lbl.setFixedWidth(width)
lbl.ensurePolished()
shown = elide_to_lines(text, lbl.font(), width - 2, lines)
lbl.setText(shown)
fm = QFontMetrics(lbl.font())
used = lines if reserve else min(lines,
line_count(shown, lbl.font(), width - 2))
lbl.setFixedHeight(fm.lineSpacing() * used + 2)
lbl.setAlignment(Qt.AlignTop | Qt.AlignLeft)
return lbl
[docs]
def text_label(ctx: Ctx, text: str, *, size: int = 13, weight: int = 400,
color: Optional[str] = None, tracking: str = "0px",
upper: bool = False) -> QLabel:
"""A plain styled label (no wrapping, sized to its own text)."""
lbl = QLabel(text.upper() if upper else text)
lbl.setStyleSheet(
f"color: {color or ctx.P['fg']}; font-size: {size}px;"
f"font-weight: {weight}; letter-spacing: {tracking};"
"background: transparent;")
return lbl
# ---------------------------------------------------------------------------
# Page-level QSS for the widgets invented here
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Page scaffold
# ---------------------------------------------------------------------------
[docs]
class Page(QWidget):
"""A 1440x900 home-screen canvas with optional window chrome.
:param ctx: the theme context.
:param chrome: draw the app's menu strip + status bar, so the space
a variant actually gets on a 1440x900 laptop is what is rendered.
:param margins: content margins of the body area.
"""
def __init__(self, ctx: Ctx, *, chrome: bool = True,
margins: Tuple[int, int, int, int] = (28, 22, 28, 18),
spacing: int = 16):
super().__init__()
self.setObjectName("Page")
self.setAttribute(Qt.WA_StyledBackground, True)
self.setStyleSheet(extra_qss(ctx))
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
if chrome:
outer.addWidget(menu_strip(ctx))
[docs]
self.middle = QHBoxLayout()
self.middle.setContentsMargins(0, 0, 0, 0)
self.middle.setSpacing(0)
outer.addLayout(self.middle, 1)
self._content = QWidget()
self._content.setObjectName("Transparent")
[docs]
self.body = QVBoxLayout(self._content)
self.body.setContentsMargins(*margins)
self.body.setSpacing(spacing)
self.middle.addWidget(self._content, 1)
self._outer = outer
self._chrome = chrome
[docs]
def add_rail(self, rail: QWidget) -> None:
"""Insert a navigation rail to the left of the content area."""
self.middle.insertWidget(0, rail)
[docs]
def add_aside(self, aside: QWidget) -> None:
"""Insert a column to the right of the content area."""
self.middle.addWidget(aside)
[docs]
def finish(self, footer: Optional[QWidget] = None,
status: Optional[str] = None) -> "Page":
"""Attach the optional footer + status bar. Returns self."""
if footer is not None:
self._outer.addWidget(footer)
if self._chrome:
self._outer.addWidget(status_bar(self.ctx, status or "Ready"))
return self
[docs]
def status_bar(ctx: Ctx, message: str = "Ready") -> QWidget:
"""The app's status bar: transient message left, app + version right."""
w = QWidget()
w.setObjectName("StatusStrip")
w.setAttribute(Qt.WA_StyledBackground, True)
w.setFixedHeight(24)
row = QHBoxLayout(w)
row.setContentsMargins(10, 0, 10, 0)
row.setSpacing(14)
row.addWidget(text_label(ctx, message, size=11, color=ctx.P["fg_muted"]))
row.addStretch(1)
row.addWidget(text_label(ctx, "Home", size=11, color=ctx.P["fg_muted"]))
row.addWidget(text_label(ctx, f"spaCR {MOCK['version']}", size=11,
color=ctx.P["fg_dim"], tracking="0.6px"))
return w
[docs]
def hint_bar(ctx: Ctx, text: str = "Hover a tile to see what it does."
) -> QLabel:
"""The sticky bottom hint bar the current home screen ships with."""
lbl = QLabel(text)
lbl.setObjectName("HintBar")
lbl.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
lbl.setFixedHeight(34)
return lbl
[docs]
def transparent(spacing: int = 0, margins=(0, 0, 0, 0), horizontal=False):
"""A bare container widget plus its layout (background transparent)."""
w = QWidget()
w.setObjectName("Transparent")
lay = QHBoxLayout(w) if horizontal else QVBoxLayout(w)
lay.setContentsMargins(*margins)
lay.setSpacing(spacing)
return w, lay
[docs]
def panel(ctx: Ctx, *, accent: bool = False, plain: bool = False,
margins=(14, 12, 14, 12), spacing: int = 8, horizontal=False):
"""A rounded surface panel plus its layout."""
frame = QFrame()
frame.setObjectName("PanelAccent" if accent
else "PanelPlain" if plain else "Panel")
lay = QHBoxLayout(frame) if horizontal else QVBoxLayout(frame)
lay.setContentsMargins(*margins)
lay.setSpacing(spacing)
return frame, lay
# ---------------------------------------------------------------------------
# Headers
# ---------------------------------------------------------------------------
[docs]
def hero(ctx: Ctx, *, compact: bool = False) -> QWidget:
"""The current home hero: logo + wordmark + one-line pitch."""
w, row = transparent(horizontal=True, spacing=16)
logo_px = 56 if compact else 96
pix = ctx.logo(logo_px)
if pix is not None:
lbl = QLabel()
lbl.setPixmap(pix)
lbl.setFixedSize(logo_px, logo_px)
lbl.setStyleSheet("background: transparent;")
row.addWidget(lbl, 0, Qt.AlignVCenter)
mark = QLabel("spaCR")
mark.setStyleSheet(
"font-family: 'Open Sans', sans-serif; font-weight: 300;"
f"font-size: {36 if compact else 56}px; color: {ctx.P['accent']};"
"letter-spacing: -1.2px; background: transparent;")
row.addWidget(mark, 0, Qt.AlignVCenter)
sub = wrapped(ctx,
"End-to-end microscopy → single-cell measurements → "
"genotype-phenotype mapping.",
520, 2, color=ctx.P["fg"], size=13)
row.addWidget(sub, 0, Qt.AlignVCenter)
row.addStretch(1)
return w
[docs]
def top_bar(ctx: Ctx, *, title: str = "spaCR",
subtitle: str = "",
actions: Sequence[Tuple[str, bool]] = ()) -> QWidget:
"""A slim brand bar: logo mark + title (+ subtitle) + right actions.
:param actions: ``(label, primary)`` pairs rendered right-aligned.
"""
w, row = transparent(horizontal=True, spacing=12)
pix = ctx.logo(34)
if pix is not None:
lbl = QLabel()
lbl.setPixmap(pix)
lbl.setFixedSize(34, 34)
lbl.setStyleSheet("background: transparent;")
row.addWidget(lbl, 0, Qt.AlignVCenter)
row.addWidget(text_label(ctx, title, size=25, weight=300,
color=ctx.P["accent"], tracking="-0.6px"),
0, Qt.AlignVCenter)
if subtitle:
row.addWidget(text_label(ctx, subtitle, size=13, weight=300,
color=ctx.P["fg_muted"]), 0, Qt.AlignVCenter)
row.addStretch(1)
for label, primary in actions:
btn = QPushButton(label)
if primary:
btn.setObjectName("PrimaryButton")
btn.setCursor(Qt.PointingHandCursor)
row.addWidget(btn, 0, Qt.AlignVCenter)
return w
[docs]
def search_box(ctx: Ctx, placeholder: str, *, big: bool = False,
width: int = 0) -> QLineEdit:
"""A real search field (the search-first variants' entry point)."""
edit = QLineEdit()
edit.setObjectName("SearchBig" if big else "Search")
edit.setPlaceholderText(placeholder)
edit.setClearButtonEnabled(False)
if width:
edit.setFixedWidth(width)
return edit
[docs]
def kbd(ctx: Ctx, text: str) -> QLabel:
"""A keycap chip, e.g. ``Ctrl+1``."""
lbl = QLabel(text)
lbl.setObjectName("Kbd")
lbl.setAlignment(Qt.AlignCenter)
return lbl
[docs]
def chip(ctx: Ctx, text: str, on: bool = False) -> QPushButton:
"""A pill-shaped filter chip."""
btn = QPushButton(text)
btn.setObjectName("ChipOn" if on else "Chip")
btn.setCursor(Qt.PointingHandCursor)
btn.setFlat(True)
return btn
# ---------------------------------------------------------------------------
# App surfaces
# ---------------------------------------------------------------------------
#: Height an ``HTile`` needs for a given icon: the QSS pads it 12 px top
#: and bottom, and the icon is the tallest thing inside.
[docs]
def htile_height(icon_px: int) -> int:
"""Minimum height an ``HTile`` needs so its icon is not cropped."""
return max(72, icon_px + 28)
#: Narrowest an ``HTile`` may be drawn before "Annotator Agreement"
#: elides, measured by bisection (see the module notes in VARIANTS.md).
#: ``{name font px: {icon px: min width}}``; the shipped tile uses the
#: 17 px subtitle size and therefore needs 255-263 px.
[docs]
HTILE_MIN_WIDTH = {
0: {32: 243, 36: 247, 40: 251, 44: 255, 52: 263},
12: {32: 192, 36: 196, 40: 200},
13: {32: 202, 36: 206, 40: 210},
14: {32: 212, 36: 216, 40: 220},
}
[docs]
def htile(ctx: Ctx, key: str, *, width: int, height: int = 0,
icon_px: int = 44, name_px: int = 0) -> QWidget:
"""One of the app's real ``HTile`` cards, at a fixed width.
:param name_px: override the tile name's font size. The shipped
tile draws it at the 17 px "subtitle" size, which is what forces
a 255 px minimum width and therefore at most five columns on a
1440 px screen; the compact variants restyle that one label.
"""
from spacr.qt.widgets.tile import HTile
tile = HTile(text=name_of(key), description="", icon=ctx.icon(key),
icon_size=icon_px)
if name_px:
tile._name_lbl.setStyleSheet(
f"font-size: {name_px}px; font-weight: 500;"
f"color: {ctx.P['fg']}; background: transparent;")
tile._name_lbl.setText(name_of(key))
tile.setFixedWidth(width)
tile.setFixedHeight(height or htile_height(icon_px))
tile.setToolTip(f"{name_of(key)} — {blurb_of(key)}")
return tile
[docs]
def htile_grid(ctx: Ctx, keys: Sequence[str], *, cols: int,
width: int, hspace: int = 8, vspace: int = 8,
icon_px: int = 44, height: int = 0,
name_px: int = 0) -> QWidget:
"""A wrapping grid of real ``HTile`` cards."""
w = QWidget()
w.setObjectName("Transparent")
grid = QGridLayout(w)
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(hspace)
grid.setVerticalSpacing(vspace)
for i, key in enumerate(keys):
grid.addWidget(htile(ctx, key, width=width, icon_px=icon_px,
height=height, name_px=name_px),
i // cols, i % cols, Qt.AlignLeft | Qt.AlignTop)
grid.setColumnStretch(cols, 1)
return w
[docs]
class BigTile(FixedButton):
"""A large illustrated launcher tile: icon above name above blurb.
Does not exist in spaCR today — this is the widget the
"large illustrated tiles" variants propose, built for real so the
layout can be judged on how it actually renders.
"""
def __init__(self, ctx: Ctx, key: str, *, width: int, height: int,
icon_px: int = 56, blurb_lines: int = 0,
accent: bool = False, badge: str = ""):
super().__init__(width, height)
self.setObjectName("BigTileAccent" if accent else "BigTile")
self.setCursor(Qt.PointingHandCursor)
self.setToolTip(f"{name_of(key)} — {blurb_of(key)}")
col = QVBoxLayout(self)
col.setContentsMargins(10, 10, 10, 10)
col.setSpacing(6)
col.addStretch(1)
icon = QLabel()
icon.setPixmap(ctx.pixmap(key, icon_px))
icon.setFixedSize(icon_px, icon_px)
icon.setStyleSheet("background: transparent;")
col.addWidget(icon, 0, Qt.AlignHCenter)
from spacr.qt.widgets.eliding import ElidingLabel
nm = ElidingLabel(name_of(key))
nm.setAlignment(Qt.AlignHCenter)
nm.setFixedWidth(width - 20)
nm.setStyleSheet(
f"color: {ctx.P['fg']}; font-size: 13px; font-weight: 500;"
"background: transparent;")
col.addWidget(nm, 0, Qt.AlignHCenter)
self._name = nm
if blurb_lines:
body = wrapped(ctx, blurb_of(key), width - 24, blurb_lines,
size=11, reserve=True)
body.setAlignment(Qt.AlignHCenter | Qt.AlignTop)
col.addWidget(body, 0, Qt.AlignHCenter)
if badge:
col.addWidget(text_label(ctx, badge, size=10, weight=600,
color=ctx.P["fg_dim"], tracking="0.6px"),
0, Qt.AlignHCenter)
col.addStretch(1)
[docs]
def big_tile_grid(ctx: Ctx, keys: Sequence[str], *, cols: int, width: int,
height: int, icon_px: int = 56, blurb_lines: int = 0,
hspace: int = 10, vspace: int = 10,
badges: Optional[dict] = None) -> QWidget:
"""A grid of :class:`BigTile`."""
w = QWidget()
w.setObjectName("Transparent")
grid = QGridLayout(w)
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(hspace)
grid.setVerticalSpacing(vspace)
for i, key in enumerate(keys):
grid.addWidget(
BigTile(ctx, key, width=width, height=height, icon_px=icon_px,
blurb_lines=blurb_lines,
badge=(badges or {}).get(key, "")),
i // cols, i % cols, Qt.AlignLeft | Qt.AlignTop)
grid.setColumnStretch(cols, 1)
return w
[docs]
class DenseRow(QPushButton):
"""A compact one-line app row: small icon, name, blurb, optional badge.
The "dense list" answer to the tile grid — many more apps above the
fold, at the cost of the tile's visual weight.
"""
def __init__(self, ctx: Ctx, key: str, *, width: int,
name_width: int = 136, icon_px: int = 20,
show_blurb: bool = True, badge: str = "",
shortcut: str = ""):
super().__init__()
self.setObjectName("DenseRow")
self.setCursor(Qt.PointingHandCursor)
self.setFixedWidth(width)
self.setFixedHeight(30)
self.setToolTip(f"{name_of(key)} — {blurb_of(key)}")
row = QHBoxLayout(self)
row.setContentsMargins(6, 0, 6, 0)
row.setSpacing(9)
icon = QLabel()
icon.setPixmap(ctx.pixmap(key, icon_px))
icon.setFixedSize(icon_px, icon_px)
icon.setStyleSheet("background: transparent;")
row.addWidget(icon, 0, Qt.AlignVCenter)
from spacr.qt.widgets.eliding import ElidingLabel
nm = ElidingLabel(name_of(key))
nm.setFixedWidth(name_width)
nm.setStyleSheet(
f"color: {ctx.P['fg']}; font-size: 13px; font-weight: 500;"
"background: transparent;")
row.addWidget(nm, 0, Qt.AlignVCenter)
self._name = nm
if show_blurb:
rest = width - 12 - icon_px - name_width - 9 * 3 - (
60 if (badge or shortcut) else 0)
if rest > 40:
blurb = QLabel()
blurb.setFixedWidth(rest)
blurb.setStyleSheet(
f"color: {ctx.P['fg_muted']}; font-size: 11px;"
"font-weight: 300; background: transparent;")
blurb.ensurePolished()
fm = QFontMetrics(blurb.font())
blurb.setText(fm.elidedText(blurb_of(key), Qt.ElideRight,
rest - 2))
row.addWidget(blurb, 0, Qt.AlignVCenter)
row.addStretch(1)
if badge:
row.addWidget(text_label(ctx, badge, size=11, weight=500,
color=ctx.P["fg_dim"]), 0,
Qt.AlignVCenter)
if shortcut:
row.addWidget(kbd(ctx, shortcut), 0, Qt.AlignVCenter)
[docs]
def dense_list(ctx: Ctx, keys: Sequence[str], *, width: int,
name_width: int = 136, show_blurb: bool = True,
badges: Optional[dict] = None,
shortcuts: Optional[dict] = None, spacing: int = 1
) -> QWidget:
"""A vertical stack of :class:`DenseRow`."""
w = QWidget()
w.setObjectName("Transparent")
col = QVBoxLayout(w)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(spacing)
for key in keys:
col.addWidget(DenseRow(ctx, key, width=width, name_width=name_width,
show_blurb=show_blurb,
badge=(badges or {}).get(key, ""),
shortcut=(shortcuts or {}).get(key, "")))
return w
# ---------------------------------------------------------------------------
# Elements that do not exist on the home screen today
# ---------------------------------------------------------------------------
[docs]
def resume_banner(ctx: Ctx, *, width: int = 0) -> QWidget:
"""ADDED: "pick up where you left off" — the single biggest button.
Names the last run and offers to resume it, open its output, or
start the next stage.
"""
app_key, plate, when = MOCK["last_run"]
frame, row = panel(ctx, accent=True, horizontal=True,
margins=(18, 14, 18, 14), spacing=16)
if width:
frame.setFixedWidth(width)
icon = QLabel()
icon.setPixmap(ctx.pixmap(app_key, 40))
icon.setFixedSize(40, 40)
icon.setStyleSheet("background: transparent;")
row.addWidget(icon, 0, Qt.AlignVCenter)
txt, col = transparent(spacing=2)
col.addWidget(text_label(ctx, f"Resume {name_of(app_key)} on {plate}",
size=16, weight=600))
col.addWidget(text_label(ctx, f"{when} · 4 of 12 plates done · "
"next stage is Annotate",
size=12, weight=300, color=ctx.P["fg_muted"]))
row.addWidget(txt, 1, Qt.AlignVCenter)
resume = QPushButton("Resume run")
resume.setObjectName("PrimaryButton")
resume.setCursor(Qt.PointingHandCursor)
row.addWidget(resume, 0, Qt.AlignVCenter)
out = QPushButton("Open output")
row.addWidget(out, 0, Qt.AlignVCenter)
return frame
[docs]
def recent_runs_strip(ctx: Ctx, *, count: int = 3, card_width: int = 300,
height: int = 92) -> QWidget:
"""ADDED: the last few runs as resumable cards."""
w = QWidget()
w.setObjectName("Transparent")
row = QHBoxLayout(w)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(10)
for key, plate, when, ok, elapsed in MOCK["recent"][:count]:
frame, col = panel(ctx, margins=(12, 10, 12, 10), spacing=5)
frame.setFixedSize(card_width, height)
head, hrow = transparent(horizontal=True, spacing=8)
icon = QLabel()
icon.setPixmap(ctx.pixmap(key, 20))
icon.setFixedSize(20, 20)
icon.setStyleSheet("background: transparent;")
hrow.addWidget(icon, 0, Qt.AlignVCenter)
hrow.addWidget(text_label(ctx, f"{name_of(key)} · {plate}", size=13,
weight=500), 0, Qt.AlignVCenter)
hrow.addStretch(1)
hrow.addWidget(text_label(ctx, "✓" if ok else "✗", size=13, weight=700,
color=ctx.P["success"] if ok
else ctx.P["error"]), 0, Qt.AlignVCenter)
col.addWidget(head)
col.addWidget(text_label(ctx, f"{when} · {elapsed}", size=11,
weight=300, color=ctx.P["fg_muted"]))
foot, frow = transparent(horizontal=True, spacing=6)
frow.addWidget(chip(ctx, "Resume"))
frow.addWidget(chip(ctx, "Settings"))
frow.addStretch(1)
col.addWidget(foot)
row.addWidget(frame)
row.addStretch(1)
return w
[docs]
def recent_runs_list(ctx: Ctx, *, count: int = 4, width: int = 320) -> QWidget:
"""ADDED: the same history as a narrow vertical list."""
frame, col = panel(ctx, margins=(14, 12, 14, 12), spacing=7)
frame.setFixedWidth(width)
col.addWidget(text_label(ctx, "Recent runs", size=11, weight=600,
color=ctx.P["fg_muted"], tracking="2px",
upper=True))
for key, plate, when, ok, elapsed in MOCK["recent"][:count]:
row_w, row = transparent(horizontal=True, spacing=8)
row.addWidget(text_label(ctx, "●" if ok else "○", size=12,
color=ctx.P["success"] if ok
else ctx.P["error"]))
row.addWidget(text_label(ctx, name_of(key), size=12, weight=500))
row.addWidget(text_label(ctx, plate, size=11, weight=300,
color=ctx.P["fg_muted"]))
row.addStretch(1)
row.addWidget(text_label(ctx, when, size=11, weight=300,
color=ctx.P["fg_dim"]))
col.addWidget(row_w)
col.addStretch(1)
return frame
[docs]
def project_status_strip(ctx: Ctx) -> QWidget:
"""ADDED: which dataset is open, how big it is, what state it is in."""
frame, row = panel(ctx, plain=True, horizontal=True,
margins=(16, 10, 16, 10), spacing=22)
frame.setFixedHeight(52)
row.addWidget(text_label(ctx, "PROJECT", size=10, weight=600,
color=ctx.P["fg_dim"], tracking="1.6px"))
row.addWidget(text_label(ctx, MOCK["project"], size=14, weight=600,
color=ctx.P["accent"]))
for value in (MOCK["plates"], MOCK["images"], MOCK["objects"]):
row.addWidget(_dot(ctx))
row.addWidget(text_label(ctx, value, size=12, weight=400,
color=ctx.P["fg_muted"]))
row.addStretch(1)
row.addWidget(text_label(ctx, "measurements.db 4.1 GB", size=11,
weight=300, color=ctx.P["fg_dim"]))
switch = QPushButton("Switch project…")
switch.setCursor(Qt.PointingHandCursor)
row.addWidget(switch)
return frame
def _dot(ctx: Ctx) -> QLabel:
return text_label(ctx, "·", size=13, color=ctx.P["fg_dim"])
[docs]
def system_panel(ctx: Ctx, *, width: int = 300, title: str = "System"
) -> QWidget:
"""ADDED: disk + GPU state, built on the real ``UsageBar`` widget."""
from spacr.qt.widgets.usage_bar import UsageBar
frame, col = panel(ctx, margins=(14, 12, 14, 12), spacing=6)
frame.setFixedWidth(width)
col.addWidget(text_label(ctx, title, size=11, weight=600,
color=ctx.P["fg_muted"], tracking="2px",
upper=True))
for label, pct, note in MOCK["system"]:
bar = UsageBar(label)
bar.set_value(pct)
col.addWidget(bar)
col.addWidget(text_label(ctx, "RTX 4090 · 1.2 TB free", size=11,
weight=300, color=ctx.P["fg_dim"]))
col.addStretch(1)
return frame
[docs]
def whats_new_panel(ctx: Ctx, *, width: int = 320, items: int = 4) -> QWidget:
"""ADDED: what changed in this version, so an upgrade is discoverable."""
frame, col = panel(ctx, margins=(14, 12, 14, 12), spacing=7)
frame.setFixedWidth(width)
head, hrow = transparent(horizontal=True, spacing=8)
hrow.addWidget(text_label(ctx, "New in " + MOCK["version"], size=11,
weight=600, color=ctx.P["fg_muted"],
tracking="2px", upper=True))
hrow.addStretch(1)
col.addWidget(head)
inner = width - 28
for line in MOCK["whats_new"][:items]:
row_w, row = transparent(horizontal=True, spacing=8)
row.setAlignment(Qt.AlignTop)
row.addWidget(text_label(ctx, "•", size=12, color=ctx.P["accent"]))
row.addWidget(wrapped(ctx, line, inner - 18, 2, size=11))
col.addWidget(row_w)
col.addStretch(1)
return frame
[docs]
def quick_start(ctx: Ctx, *, width: int = 0, steps: Sequence[
Tuple[str, str, str]] = ()) -> QWidget:
"""ADDED: a guided first-run path — three numbered steps with buttons."""
steps = steps or (
("1", "Point spaCR at your images",
"A Yokogawa/ND2/CZI folder, or import someone else's project."),
("2", "Segment and measure",
"Mask makes the objects, Measure turns them into a table."),
("3", "Call your hits",
"Annotate, classify, then map barcodes to scores."),
)
buttons = (("Choose folder…", True), ("Run Mask → Measure", True),
("Open Annotate", False))
w = QWidget()
w.setObjectName("Transparent")
row = QHBoxLayout(w)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(12)
card_w = 348
for (num, title, body), (blabel, primary) in zip(steps, buttons):
frame, col = panel(ctx, margins=(16, 14, 16, 14), spacing=8)
frame.setFixedSize(card_w, 168)
top, trow = transparent(horizontal=True, spacing=10)
badge = QLabel(num)
badge.setFixedSize(26, 26)
badge.setAlignment(Qt.AlignCenter)
badge.setStyleSheet(
f"background: {ctx.P['accent_soft']}; color: {ctx.P['accent']};"
f"border: 1px solid {ctx.P['accent_lo']}; border-radius: 13px;"
"font-size: 13px; font-weight: 700;")
trow.addWidget(badge, 0, Qt.AlignTop)
trow.addWidget(wrapped(ctx, title, card_w - 32 - 36, 2, size=15,
weight=600, color=ctx.P["fg"]), 1)
col.addWidget(top)
col.addWidget(wrapped(ctx, body, card_w - 32, 3, size=12))
col.addStretch(1)
btn = QPushButton(blabel)
if primary:
btn.setObjectName("PrimaryButton")
btn.setCursor(Qt.PointingHandCursor)
col.addWidget(btn, 0, Qt.AlignLeft)
row.addWidget(frame)
row.addStretch(1)
return w
[docs]
def start_run_panel(ctx: Ctx, *, width: int = 0, height: int = 210
) -> QWidget:
"""ADDED: one prominent "start a run" path — folder, pipeline, Run."""
frame, col = panel(ctx, margins=(22, 18, 22, 18), spacing=12)
if width:
frame.setFixedWidth(width)
frame.setFixedHeight(height)
col.addWidget(text_label(ctx, "Start a run", size=22, weight=500,
tracking="-0.3px"))
col.addWidget(text_label(
ctx, "Everything else on this page is optional.", size=12,
weight=300, color=ctx.P["fg_muted"]))
form, frow = transparent(horizontal=True, spacing=10)
src = search_box(ctx, "/data/toxo_mito_screen/plate_08")
src.setFixedWidth(430)
frow.addWidget(src)
browse = QPushButton("Browse…")
browse.setCursor(Qt.PointingHandCursor)
frow.addWidget(browse)
frow.addStretch(1)
col.addWidget(form)
pipe, prow = transparent(horizontal=True, spacing=8)
prow.addWidget(text_label(ctx, "Pipeline", size=12, weight=500,
color=ctx.P["fg_muted"]))
for label, on in (("Mask", True), ("Measure", True), ("Annotate", False),
("Classify", False), ("Regression", False)):
prow.addWidget(chip(ctx, label, on=on))
prow.addStretch(1)
run = QPushButton("Run")
run.setObjectName("PrimaryButton")
run.setCursor(Qt.PointingHandCursor)
run.setFixedWidth(120)
prow.addWidget(run)
col.addWidget(pipe)
col.addStretch(1)
return frame
[docs]
def pinned_row(ctx: Ctx, keys: Sequence[str] = (), *, tile_w: int = 150,
tile_h: int = 108) -> QWidget:
"""ADDED: a pinned-favourites row the user curates themselves."""
keys = list(keys or common.PINNED)
w = QWidget()
w.setObjectName("Transparent")
row = QHBoxLayout(w)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(10)
for key in keys:
row.addWidget(BigTile(ctx, key, width=tile_w, height=tile_h,
icon_px=40))
add = FixedButton(52, tile_h)
add.setText("+")
add.setObjectName("BigTile")
add.setToolTip("Pin another app")
add.setStyleSheet(f"color: {ctx.P['fg_dim']}; font-size: 22px;")
row.addWidget(add)
row.addStretch(1)
return w
[docs]
def queue_panel(ctx: Ctx, *, width: int = 320) -> QWidget:
"""ADDED: what is queued to run next, straight on the home screen."""
frame, col = panel(ctx, margins=(14, 12, 14, 12), spacing=7)
frame.setFixedWidth(width)
col.addWidget(text_label(ctx, "Queued", size=11, weight=600,
color=ctx.P["fg_muted"], tracking="2px",
upper=True))
for plate, pipeline, state in MOCK["queue"]:
row_w, row = transparent(horizontal=True, spacing=8)
row.addWidget(text_label(ctx, plate, size=12, weight=500))
row.addWidget(text_label(ctx, pipeline, size=11, weight=300,
color=ctx.P["fg_muted"]))
row.addStretch(1)
row.addWidget(text_label(ctx, state, size=11, weight=300,
color=ctx.P["fg_dim"]))
col.addWidget(row_w)
bar = QProgressBar()
bar.setRange(0, 100)
bar.setValue(34)
bar.setTextVisible(False)
col.addWidget(bar)
col.addWidget(text_label(ctx, "plate_07 · Measure · 34 %", size=11,
weight=300, color=ctx.P["fg_dim"]))
col.addStretch(1)
return frame
[docs]
def stat_row(ctx: Ctx, stats: Sequence[Tuple[str, str]], *,
height: int = 74) -> QWidget:
"""ADDED: a row of big-number tiles (runs, plates, objects, models)."""
w = QWidget()
w.setObjectName("Transparent")
row = QHBoxLayout(w)
row.setContentsMargins(0, 0, 0, 0)
row.setSpacing(10)
for value, label in stats:
frame, col = panel(ctx, margins=(14, 8, 14, 8), spacing=0)
frame.setFixedHeight(height)
col.addWidget(text_label(ctx, value, size=24, weight=300,
tracking="-0.5px"))
col.addWidget(text_label(ctx, label, size=10, weight=600,
color=ctx.P["fg_muted"], tracking="1.4px",
upper=True))
row.addWidget(frame, 1)
return w
# ---------------------------------------------------------------------------
# Navigation surfaces
# ---------------------------------------------------------------------------
[docs]
def cat_rail(ctx: Ctx, titles: Sequence[str], *, selected: int = 0,
width: int = 232, header: str = "",
counts: Optional[Sequence[int]] = None) -> QWidget:
"""A left rail of CATEGORIES (not apps) beside a content pane."""
holder = QWidget()
holder.setObjectName("RailBg")
holder.setAttribute(Qt.WA_StyledBackground, True)
holder.setFixedWidth(width)
col = QVBoxLayout(holder)
col.setContentsMargins(0, 14, 0, 14)
col.setSpacing(8)
if header:
lbl = text_label(ctx, header, size=11, weight=600,
color=ctx.P["fg_dim"], tracking="2px", upper=True)
lbl.setContentsMargins(16, 0, 16, 0)
col.addWidget(lbl)
lst = QListWidget()
lst.setObjectName("CatRail")
lst.setFrameShape(QFrame.NoFrame)
lst.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
lst.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
for i, title in enumerate(titles):
label = title
if counts is not None:
label = f"{title} {counts[i]}"
QListWidgetItem(label, lst)
lst.setCurrentRow(selected)
col.addWidget(lst, 1)
return holder