diff --git a/PyReconstruct/modules/gui/dialog/all_options.py b/PyReconstruct/modules/gui/dialog/all_options.py index 4fbcc211..85c25149 100644 --- a/PyReconstruct/modules/gui/dialog/all_options.py +++ b/PyReconstruct/modules/gui/dialog/all_options.py @@ -257,6 +257,8 @@ def setOption(response): ("System", mode == "system"), ("Light", mode == "light"), ("Dark", mode == "dark"), + ("Studio (dark)", mode == "studio"), + ("Atlas (light)", mode == "atlas"), )], ] @@ -266,9 +268,10 @@ def setOption(response): if response[0][0][1]: theme = "system" elif response[0][1][1]: theme = "light" elif response[0][2][1]: theme = "dark" + elif len(response[0]) > 3 and response[0][3][1]: theme = "studio" + elif len(response[0]) > 4 and response[0][4][1]: theme = "atlas" self.series.setOption("theme", theme) - self.addOptionWidget("theme", structure, setOption) # scale bar opts diff --git a/PyReconstruct/modules/gui/main/main_window.py b/PyReconstruct/modules/gui/main/main_window.py index 1557e31e..cbf26a94 100644 --- a/PyReconstruct/modules/gui/main/main_window.py +++ b/PyReconstruct/modules/gui/main/main_window.py @@ -3070,10 +3070,12 @@ def load3DScene(self): def setTheme(self, new_theme=None): """Set the UI theme. - Modes: "system" (follow the OS light/dark scheme), "light", "dark". - Persisted app-wide via QSettings; legacy "default"/"qdark" are accepted - and migrated. Called with None from the menu to prompt; called with a - stored value at startup / after the options dialog to (re)apply.""" + Modes: "system" (follow the OS light/dark scheme), "light", "dark", and + the named "studio" (dark) / "atlas" (light) concept themes (the v1.30 + UI overhaul's teal palette). Persisted app-wide via QSettings; legacy + "default"/"qdark" are accepted and migrated. Called with None from the + menu to prompt; called with a stored value at startup / after the + options dialog to (re)apply.""" if new_theme is None: mode = theme.normalize_mode(self.series.getOption("theme")) structure = [ @@ -3081,7 +3083,9 @@ def setTheme(self, new_theme=None): [("radio", ("System", mode == "system"), ("Light", mode == "light"), - ("Dark", mode == "dark"))] + ("Dark", mode == "dark"), + ("Studio (dark)", mode == "studio"), + ("Atlas (light)", mode == "atlas"))] ] response, confirmed = QuickDialog.get( self, structure, "Theme" @@ -3095,6 +3099,10 @@ def setTheme(self, new_theme=None): new_theme = "light" elif response[0][2][1]: new_theme = "dark" + elif response[0][3][1]: + new_theme = "studio" + elif response[0][4][1]: + new_theme = "atlas" else: return diff --git a/PyReconstruct/modules/gui/studio/__init__.py b/PyReconstruct/modules/gui/studio/__init__.py new file mode 100644 index 00000000..51e95edb --- /dev/null +++ b/PyReconstruct/modules/gui/studio/__init__.py @@ -0,0 +1,21 @@ +"""The "Studio" layout — the v1.30 UI overhaul's foundation. + +A self-contained set of bespoke Qt widgets implementing the Studio / Atlas +layout grammar from the canonical spec (activity rail · first-class Objects +panel · dark canvas with glassy floating controls · flush tool rail · +trace-palette strip · mono status bar), composed by :class:`~.shell.StudioShell`. + +These are a pure **view layer**: they import only PySide6 and the theme/icon +utilities, take their content through plain-data setters, and announce user +intent through signals. They hold no reference to the field, series, or any +heavy runtime, so they construct headlessly, render offscreen for sign-off, and +can be adopted incrementally by the main window (which feeds them real data). + +The one interactive accent is teal (``#37c0a6``); the EM canvas — and the +controls floating over it — stay dark in both Studio and Atlas. All chrome +color comes from :func:`PyReconstruct.modules.gui.utils.theme.studio_tokens`. +""" + +from .shell import StudioShell + +__all__ = ["StudioShell"] diff --git a/PyReconstruct/modules/gui/studio/_common.py b/PyReconstruct/modules/gui/studio/_common.py new file mode 100644 index 00000000..31d87f2d --- /dev/null +++ b/PyReconstruct/modules/gui/studio/_common.py @@ -0,0 +1,89 @@ +"""Small shared helpers for the Studio widgets.""" +import os + +from PySide6.QtCore import QSize +from PySide6.QtWidgets import QToolButton + +from ..utils import icons + +# The shipped app icon (the fork's low-poly Python mark on a squircle), resolved +# relative to the package so the view layer needs no new cross-module import and +# still works headless / offscreen. Dark and light squircles match the two +# shells (as gui.main uses for the OS window icon). +_ASSET_IMG = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "assets", "img")) +_APP_ICON = {"dark": "PyReconstruct.png", "light": "PyReconstruct-light.png"} + + +def app_icon_path(family="dark"): + """Filesystem path to the squircle app icon for a color family, or None.""" + path = os.path.join(_ASSET_IMG, _APP_ICON.get(family, _APP_ICON["dark"])) + return path if os.path.exists(path) else None + + +def logo_path(): + """Filesystem path to the bare (transparent) fork logo, or None if missing. + + The flat mark — no squircle — reads on both the dark and light title bars. + """ + path = os.path.join(_ASSET_IMG, "logo.png") + return path if os.path.exists(path) else None + + +def repolish(widget): + """Re-evaluate a widget's QSS after a dynamic property changed. + + Qt only re-runs property-dependent selectors (e.g. ``[active="true"]``) when + a widget is unpolished and re-polished; setting the property alone is not + enough. + """ + style = widget.style() + style.unpolish(widget) + style.polish(widget) + widget.update() + + +class IconTileButton(QToolButton): + """A square icon button that tints its stroked-vector icon to the theme. + + Used for both rails: it carries the resting and active icon tints, flips an + ``active`` QSS property (so the stylesheet paints the lit-from-within state), + and re-renders the icon to the right tint on every state or theme change. + The artwork is the shared :mod:`gui.utils.icon_svgs` set, rendered through + :func:`gui.utils.icons.tool_icon` (never a unicode glyph). + """ + + def __init__(self, key, icon_name, object_name, tile_px, icon_px, parent=None): + super().__init__(parent) + self.key = key + self.icon_name = icon_name + self._icon_px = icon_px + self._rest = "#9aa7b5" + self._active = "#37c0a6" + self.setObjectName(object_name) + self.setFixedSize(tile_px, tile_px) + self.setIconSize(QSize(icon_px, icon_px)) + self.setCheckable(False) + self.setFocusPolicy(self.focusPolicy().NoFocus) + self.setCursor(self.cursor().shape()) + self.setProperty("active", False) + self._render_icon() + + def retint(self, rest_color, active_color): + """Set the resting / active icon tints (typically on a theme change).""" + self._rest = rest_color + self._active = active_color + self._render_icon() + + def set_active(self, on: bool): + """Mark this tile active: flip the QSS property and re-tint the icon.""" + self.setProperty("active", bool(on)) + repolish(self) + self._render_icon() + + def is_active(self) -> bool: + return bool(self.property("active")) + + def _render_icon(self): + color = self._active if self.is_active() else self._rest + self.setIcon(icons.tool_icon(self.icon_name, self._icon_px, color)) diff --git a/PyReconstruct/modules/gui/studio/canvas.py b/PyReconstruct/modules/gui/studio/canvas.py new file mode 100644 index 00000000..e73d1e41 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/canvas.py @@ -0,0 +1,407 @@ +"""The canvas — the dark work surface and its glassy floating controls. + +The EM canvas is always dark (in Studio *and* Atlas). Controls do not crowd it: +they float as glassy cards over the imagery so the work owns the screen — + + * active-object "needs review" badge (top-left), + * scale bar (bottom-left), + * section-nav pill ``‹ section 148 / 287 ›`` (bottom-center), + * brightness / contrast card with teal-thumb tracks (bottom-right). + +Brightness/contrast live here, on the canvas — not in a docked slider. The +floats are children repositioned on resize. :meth:`set_canvas_widget` is the +seam where the real field widget mounts later; until then the canvas paints a +representative dark EM/segmentation backdrop so the layout reads honestly. +""" +from PySide6.QtCore import Qt, Signal, QRectF, QPointF +from PySide6.QtGui import ( + QPainter, QColor, QBrush, QPen, QRadialGradient, QLinearGradient, +) +from PySide6.QtWidgets import ( + QWidget, QFrame, QHBoxLayout, QVBoxLayout, QLabel, QToolButton, QSizePolicy, +) + +from ..utils import theme, icons + +#: curation status -> (chrome token key, badge phrasing) +_STATUS = { + "curated": ("ok", "curated"), + "review": ("warn", "needs review"), + "flagged": ("bad", "flagged"), +} + + +class _Dot(QLabel): + """A small status dot (a data hue, constant across themes).""" + + def __init__(self, d=8, parent=None): + super().__init__(parent) + self._d = d + self.setFixedSize(d, d) + + def set_color(self, color_hex): + self.setStyleSheet(f"background:{color_hex}; border-radius:{self._d // 2}px;") + + +class TealTrack(QWidget): + """A minimal slider: a thin raised track with a teal thumb (0–100).""" + + valueChanged = Signal(int) + + def __init__(self, value=50, parent=None): + super().__init__(parent) + self._value = max(0, min(100, value)) + self._track = "#222a35" + self._accent = "#37c0a6" + self.setMinimumWidth(64) + self.setFixedHeight(14) + self.setCursor(Qt.PointingHandCursor) + + def set_tokens(self, tokens): + self._track = tokens["raised_dark"] + self._accent = tokens["accent"] + self.update() + + def value(self): + return self._value + + def setValue(self, v): + v = max(0, min(100, int(v))) + if v != self._value: + self._value = v + self.update() + self.valueChanged.emit(v) + + def _value_from_x(self, x): + usable = max(1, self.width() - 10) + return round((x - 5) / usable * 100) + + def mousePressEvent(self, event): + self.setValue(self._value_from_x(event.position().x())) + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: + self.setValue(self._value_from_x(event.position().x())) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + w, h = self.width(), self.height() + cy = h / 2 + p.setPen(Qt.NoPen) + p.setBrush(QColor(self._track)) + p.drawRoundedRect(QRectF(0, cy - 2, w, 4), 2, 2) + cx = 5 + (w - 10) * self._value / 100 + p.setBrush(QColor(self._accent)) + p.drawEllipse(QPointF(cx, cy), 5, 5) + p.end() + + +def _float_frame(name="studioFloat"): + f = QFrame() + f.setObjectName(name) + f.setAttribute(Qt.WA_StyledBackground, True) + return f + + +class ActiveObjectBadge(QFrame): + """Top-left glassy badge: a status dot + ``name · ``.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioFloat") + self.setAttribute(Qt.WA_StyledBackground, True) + lay = QHBoxLayout(self) + lay.setContentsMargins(9, 5, 10, 5) + lay.setSpacing(7) + self._dot = _Dot(8, self) + self._text = QLabel("", self) + self._text.setObjectName("studioFloatText") + lay.addWidget(self._dot) + lay.addWidget(self._text) + self._tok = theme.studio_tokens() + + def set_tokens(self, tokens): + self._tok = tokens + self._refresh() + + def set_object(self, name, status): + self._name = name + self._status = status + self._refresh() + + def _refresh(self): + name = getattr(self, "_name", "") + status = getattr(self, "_status", None) + tok_key, phrase = _STATUS.get(status, ("warn", "needs review")) + self._dot.set_color(self._tok[tok_key]) + self._text.setText(f"{name} · {phrase}" if name else phrase) + self.adjustSize() + + +class ScaleBar(QWidget): + """Bottom-left scale bar: a solid bar + a mono measurement (no card).""" + + def __init__(self, parent=None): + super().__init__(parent) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(4) + self._bar = QFrame(self) + self._bar.setFixedSize(80, 3) + self._text = QLabel("2 µm", self) + self._text.setObjectName("studioScaleText") + lay.addWidget(self._bar) + lay.addWidget(self._text) + self._tok = theme.studio_tokens() + self._apply() + + def set_tokens(self, tokens): + self._tok = tokens + self._apply() + + def set_text(self, text): + self._text.setText(text) + self.adjustSize() + + def _apply(self): + self._bar.setStyleSheet(f"background:{self._tok['scale_bar']}; border-radius:1px;") + + +class SectionNavPill(QFrame): + """Bottom-center glassy pill: ``‹ section / ›``.""" + + stepped = Signal(int) # -1 previous, +1 next + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioFloat") + self.setAttribute(Qt.WA_StyledBackground, True) + lay = QHBoxLayout(self) + lay.setContentsMargins(10, 6, 10, 6) + lay.setSpacing(8) + self._prev = self._chev("chevron_left", -1) + self._label = QLabel("", self) + self._label.setObjectName("studioSecnavLabel") + self._label.setTextFormat(Qt.RichText) + self._next = self._chev("chevron_right", +1) + lay.addWidget(self._prev) + lay.addWidget(self._label) + lay.addWidget(self._next) + self._tok = theme.studio_tokens() + self._idx, self._total = 1, 1 + self._refresh_icons() + self.set_section(148, 287) + + def _chev(self, icon_name, delta): + b = QToolButton(self) + b.setObjectName("studioSecnavButton") + b.setFixedSize(24, 24) + b.setFocusPolicy(Qt.NoFocus) + b.setCursor(Qt.PointingHandCursor) + b._icon_name = icon_name + b.clicked.connect(lambda _=False, d=delta: self.stepped.emit(d)) + return b + + def set_tokens(self, tokens): + self._tok = tokens + self._refresh_icons() + self._refresh_label() + + def set_section(self, idx, total): + self._idx, self._total = idx, total + self._refresh_label() + + def _refresh_icons(self): + for b in (self._prev, self._next): + b.setIcon(icons.tool_icon(b._icon_name, 14, self._tok["float_muted"])) + + def _refresh_label(self): + faint = self._tok["float_faint"] + self._label.setText( + f"section {self._idx}" + f" / {self._total}" + ) + self.adjustSize() + + +class BrightnessContrastCard(QFrame): + """Bottom-right glassy card: brightness + contrast on teal-thumb tracks.""" + + brightness_changed = Signal(int) + contrast_changed = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioFloat") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(152) + lay = QVBoxLayout(self) + lay.setContentsMargins(10, 8, 11, 8) + lay.setSpacing(6) + self.bright = self._row(lay, "Bright") + self.contr = self._row(lay, "Contr") + self.bright.valueChanged.connect(self.brightness_changed) + self.contr.valueChanged.connect(self.contrast_changed) + self._tok = theme.studio_tokens() + + def _row(self, parent_layout, label): + row = QHBoxLayout() + row.setSpacing(8) + lab = QLabel(label, self) + lab.setObjectName("studioFloatLabel") + lab.setFixedWidth(34) + track = TealTrack(50, self) + track.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + row.addWidget(lab) + row.addWidget(track, 1) + parent_layout.addLayout(row) + return track + + def set_tokens(self, tokens): + self._tok = tokens + self.bright.set_tokens(tokens) + self.contr.set_tokens(tokens) + + def set_values(self, brightness, contrast): + self.bright.setValue(brightness) + self.contr.setValue(contrast) + + +class CanvasArea(QWidget): + """The dark canvas; hosts the glassy floats and (later) the real field.""" + + section_changed = Signal(int) + brightness_changed = Signal(int) + contrast_changed = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioCanvas") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setMinimumSize(420, 320) + self._tok = theme.studio_tokens() + self._canvas_widget = None + + self.badge = ActiveObjectBadge(self) + self.scalebar = ScaleBar(self) + self.secnav = SectionNavPill(self) + self.bc = BrightnessContrastCard(self) + + self.secnav.stepped.connect(self.section_changed) + self.bc.brightness_changed.connect(self.brightness_changed) + self.bc.contrast_changed.connect(self.contrast_changed) + + self.badge.set_object("spine_042", "review") + + # --- public API ------------------------------------------------------- + def set_canvas_widget(self, widget): + """Mount a real canvas (the field widget); the demo backdrop yields.""" + if self._canvas_widget is not None: + self._canvas_widget.setParent(None) + self._canvas_widget = widget + if widget is not None: + widget.setParent(self) + widget.lower() + widget.show() + self._reposition() + self.update() + + def set_active_object(self, name, status): + self.badge.set_object(name, status) + self._reposition() + + def set_section(self, idx, total): + self.secnav.set_section(idx, total) + self._reposition() + + def set_scale(self, text): + self.scalebar.set_text(text) + self._reposition() + + def set_brightness(self, value): + self.bc.bright.setValue(value) + + def set_contrast(self, value): + self.bc.contr.setValue(value) + + def apply_theme(self, theme_name=None): + self._tok = theme.studio_tokens(theme_name) + for w in (self.badge, self.scalebar, self.secnav, self.bc): + w.set_tokens(self._tok) + self.update() + + # --- layout ----------------------------------------------------------- + def resizeEvent(self, event): + super().resizeEvent(event) + self._reposition() + + def showEvent(self, event): + super().showEvent(event) + self._reposition() + + def _reposition(self): + w, h = self.width(), self.height() + if self._canvas_widget is not None: + self._canvas_widget.setGeometry(0, 0, w, h) + for f in (self.badge, self.scalebar, self.secnav, self.bc): + f.adjustSize() + self.badge.move(14, 14) + self.scalebar.move(16, h - self.scalebar.height() - 16) + self.secnav.move((w - self.secnav.width()) // 2, h - self.secnav.height() - 14) + self.bc.move(w - self.bc.width() - 14, h - self.bc.height() - 14) + for f in (self.badge, self.scalebar, self.secnav, self.bc): + f.raise_() + + # --- representative backdrop (until the real field mounts) ------------ + def paintEvent(self, _event): + if self._canvas_widget is not None: + return + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + w, h = self.width(), self.height() + tok = self._tok + p.fillRect(self.rect(), QColor(tok["canvas_bg"])) + + # faint checker, like the mockup's tiled ground + step = 22 + p.setPen(Qt.NoPen) + p.setBrush(QColor(tok["canvas_bg_2"])) + for yy in range(0, h, step): + for xx in range(0, w, step): + if ((xx // step) + (yy // step)) % 2 == 0: + p.drawRect(xx, yy, step, step) + + # Demo backdrop hues below are representative EM/segmentation imagery — + # NOT themeable chrome and NOT data the panel reads; they only exist + # until set_canvas_widget() mounts the real field. + # soft EM-ish blobs + for fx, fy, fr, c in ( + (0.40, 0.44, 0.20, "#5b6675"), + (0.66, 0.60, 0.26, "#474f5b"), + (0.54, 0.30, 0.15, "#6a7280"), + ): + cx, cy, r = w * fx, h * fy, min(w, h) * fr + g = QRadialGradient(cx, cy, r) + g.setColorAt(0.0, QColor(c)) + base = QColor(c) + base.setAlpha(0) + g.setColorAt(1.0, base) + p.setBrush(QBrush(g)) + p.drawEllipse(QPointF(cx, cy), r, r * 0.78) + + # translucent segmentation ellipses in data colors (screen blend) + p.setCompositionMode(QPainter.CompositionMode_Screen) + for fx, fy, fw, fh, c in ( + (0.30, 0.40, 0.22, 0.30, "#37c0a6"), + (0.58, 0.56, 0.30, 0.34, "#5ab0f0"), + (0.48, 0.26, 0.16, 0.20, "#f0a6d8"), + (0.64, 0.36, 0.13, 0.17, "#f4bd4f"), + ): + col = QColor(c) + col.setAlpha(120) + p.setBrush(col) + p.setPen(Qt.NoPen) + p.drawEllipse(QRectF(w * fx, h * fy, w * fw, h * fh)) + p.setCompositionMode(QPainter.CompositionMode_SourceOver) + p.end() diff --git a/PyReconstruct/modules/gui/studio/objects_panel.py b/PyReconstruct/modules/gui/studio/objects_panel.py new file mode 100644 index 00000000..6b4e9243 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/objects_panel.py @@ -0,0 +1,295 @@ +"""The Objects panel — the promoted, first-class, filterable object list. + +268px wide, ``panel`` ground. A tracked uppercase ``OBJECTS`` header with a mono +count; a filter field; a model/view list whose rows read +``[swatch] [name · type] …… [status dot]``; and a curation legend in the footer. + +The list is a ``QListView`` over a lightweight model with a custom delegate, so +it virtualizes (only visible rows are painted) and scales to thousands of +objects. The delegate honours the spec's row anatomy precisely: the data color +is a small *swatch* (never tinted text), the type is a faint mono suffix, the +curation status is a colored dot, and the selected row carries a teal left bar +over a wash that fades to nothing. + +Pure view layer: feed it dicts via :meth:`set_objects` +(``{"name", "type", "color", "status"}``); it emits :attr:`object_activated`. +""" +from PySide6.QtCore import ( + Qt, Signal, QAbstractListModel, QModelIndex, QSortFilterProxyModel, QSize, QRectF, +) +from PySide6.QtGui import QColor, QBrush, QLinearGradient, QPainter, QPen +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QListView, + QStyledItemDelegate, QStyle, +) + +from ..utils import theme, icons + +_OBJ_ROLE = Qt.UserRole + 1 +#: curation status -> chrome token key for the status dot +_STATUS_TOKEN = {"curated": "ok", "review": "warn", "flagged": "bad"} +_ROW_H = 30 + + +class _ObjectModel(QAbstractListModel): + """Holds the object dicts; exposes each row's dict under ``_OBJ_ROLE``.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._objs = [] + + def set_objects(self, objs): + self.beginResetModel() + self._objs = list(objs) + self.endResetModel() + + def rowCount(self, parent=QModelIndex()): + return 0 if parent.isValid() else len(self._objs) + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid(): + return None + obj = self._objs[index.row()] + if role == _OBJ_ROLE: + return obj + if role == Qt.DisplayRole: + return obj.get("name", "") + return None + + +class _ObjectFilter(QSortFilterProxyModel): + """Case-insensitive substring filter over name + type.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._needle = "" + + def set_needle(self, text): + self._needle = (text or "").strip().lower() + self.invalidateRowsFilter() + + def filterAcceptsRow(self, row, parent): + if not self._needle: + return True + obj = self.sourceModel().index(row, 0, parent).data(_OBJ_ROLE) or {} + hay = f"{obj.get('name', '')} {obj.get('type', '')}".lower() + return self._needle in hay + + +class _ObjectRowDelegate(QStyledItemDelegate): + """Paints one object row per the spec's anatomy.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._tok = theme.studio_tokens() + self._mono = QLabel().font() # cloned below in paint via setFamily + self._mono.setStyleHint(self._mono.StyleHint.Monospace) + self._mono.setFamily("DejaVu Sans Mono") + + def set_tokens(self, tokens): + self._tok = tokens + + def sizeHint(self, option, index): + return QSize(option.rect.width(), _ROW_H) + + def paint(self, painter, option, index): + obj = index.data(_OBJ_ROLE) or {} + tok = self._tok + rect = option.rect + painter.save() + painter.setRenderHint(QPainter.Antialiasing, True) + + selected = bool(option.state & QStyle.State_Selected) + if selected: + grad = QLinearGradient(rect.left(), 0, rect.right(), 0) + grad.setColorAt(0.0, QColor(tok["row_sel_a"])) + grad.setColorAt(1.0, QColor(0, 0, 0, 0)) + painter.fillRect(rect, QBrush(grad)) + painter.fillRect(rect.left(), rect.top(), 2, rect.height(), + QColor(tok["row_sel_bar"])) + elif option.state & QStyle.State_MouseOver: + hov = QColor(tok["row_sel_a"]) + hov.setAlpha(90) + painter.fillRect(rect, hov) + + # per-row hairline (border-bottom) + painter.setPen(QPen(QColor(tok["line_soft"]), 1)) + painter.drawLine(rect.left(), rect.bottom(), rect.right(), rect.bottom()) + + pad = 11 + # data-color swatch (11x11, radius 3) — NEVER tinted text + sw = 11 + sw_x = rect.left() + pad + sw_y = rect.center().y() - sw // 2 + 1 + painter.setPen(QPen(QColor(255, 255, 255, 28), 1)) + painter.setBrush(QColor(obj.get("color", "#888888"))) + painter.drawRoundedRect(QRectF(sw_x, sw_y, sw, sw), 3, 3) + + # curation status dot (8px), right-aligned + dot = 8 + dot_x = rect.right() - pad - dot + status = obj.get("status") + if status in _STATUS_TOKEN: + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(tok[_STATUS_TOKEN[status]])) + painter.drawEllipse(QRectF(dot_x, rect.center().y() - dot / 2, dot, dot)) + + # name (ink) + " · type" (faint mono), elided to fit + name_x = sw_x + sw + 8 + avail = dot_x - 8 - name_x + name_font = option.font + type_font = self._mono + type_font.setPointSizeF(max(7.5, name_font.pointSizeF() - 1.5) + if name_font.pointSizeF() > 0 else 8.5) + painter.setFont(type_font) + type_fm = painter.fontMetrics() + type_text = f" · {obj['type']}" if obj.get("type") else "" + type_w = type_fm.horizontalAdvance(type_text) + + painter.setFont(name_font) + name_fm = painter.fontMetrics() + name = name_fm.elidedText(obj.get("name", ""), Qt.ElideRight, max(0, avail - type_w)) + name_w = name_fm.horizontalAdvance(name) + baseline = rect.center().y() + name_fm.ascent() // 2 - 1 + painter.setPen(QColor(tok["ink"])) + painter.drawText(name_x, baseline, name) + if type_text: + painter.setFont(type_font) + painter.setPen(QColor(tok["faint"])) + painter.drawText(name_x + name_w, baseline, type_text) + + painter.restore() + + +def _dot(color_hex, d=8): + """A small colored status dot as a QLabel (for the legend).""" + lab = QLabel() + lab.setFixedSize(d, d) + lab.setStyleSheet(f"background:{color_hex}; border-radius:{d // 2}px;") + return lab + + +class ObjectsPanel(QWidget): + """First-class Objects list panel; emits :attr:`object_activated`.""" + + object_activated = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioObjectsPanel") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(268) + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # header: TITLE + mono count + header = QWidget(self) + header.setObjectName("studioPanelHeader") + header.setAttribute(Qt.WA_StyledBackground, True) + hl = QHBoxLayout(header) + hl.setContentsMargins(11, 8, 11, 8) + self._title = QLabel("OBJECTS", header) + self._title.setObjectName("studioPanelTitle") + self._count = QLabel("0", header) + self._count.setObjectName("studioPanelCount") + hl.addWidget(self._title) + hl.addStretch(1) + hl.addWidget(self._count) + root.addWidget(header) + + # filter field (with a vector magnifier, never a unicode glyph) + filt_wrap = QWidget(self) + fwl = QVBoxLayout(filt_wrap) + fwl.setContentsMargins(11, 8, 11, 8) + self.filter = QLineEdit(filt_wrap) + self.filter.setObjectName("studioFilter") + self.filter.setPlaceholderText("Filter objects…") + self.filter.setClearButtonEnabled(True) + self._search_action = self.filter.addAction( + icons.tool_icon("search", 16, theme.studio_tokens()["faint"]), + QLineEdit.LeadingPosition, + ) + fwl.addWidget(self.filter) + root.addWidget(filt_wrap) + + # the list (model / filter / view + custom delegate) + self._model = _ObjectModel(self) + self._proxy = _ObjectFilter(self) + self._proxy.setSourceModel(self._model) + self.view = QListView(self) + self.view.setObjectName("studioObjectList") + self.view.setModel(self._proxy) + self._delegate = _ObjectRowDelegate(self.view) + self.view.setItemDelegate(self._delegate) + self.view.setMouseTracking(True) + self.view.setUniformItemSizes(True) + self.view.setSelectionMode(QListView.SingleSelection) + self.view.setVerticalScrollMode(QListView.ScrollPerPixel) + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + root.addWidget(self.view, 1) + + # legend footer: Curated / Review / Flagged + self._legend = QWidget(self) + self._legend.setObjectName("studioLegend") + self._legend.setAttribute(Qt.WA_StyledBackground, True) + ll = QHBoxLayout(self._legend) + ll.setContentsMargins(11, 8, 11, 8) + ll.setSpacing(11) + self._legend_dots = [] + for label, tok_key in (("Curated", "ok"), ("Review", "warn"), ("Flagged", "bad")): + cell = QHBoxLayout() + cell.setSpacing(5) + dot = _dot(theme.studio_tokens()[tok_key]) + self._legend_dots.append((dot, tok_key)) + text = QLabel(label, self._legend) + text.setObjectName("studioLegendLabel") + cell.addWidget(dot) + cell.addWidget(text) + ll.addLayout(cell) + ll.addStretch(1) + root.addWidget(self._legend) + + # wiring + self.filter.textChanged.connect(self._on_filter) + self.view.selectionModel().currentChanged.connect(self._on_current) + + # --- public API ------------------------------------------------------- + def set_title(self, title): + """Repoint the panel (e.g. OBJECTS / TRACES / SECTIONS / FLAGS).""" + self._title.setText(title.upper()) + + def set_objects(self, objs): + """Replace the list contents (a list of object dicts).""" + self._model.set_objects(objs) + self._update_count() + if self._proxy.rowCount(): + self.view.setCurrentIndex(self._proxy.index(0, 0)) + + def selected_name(self): + idx = self.view.currentIndex() + obj = idx.data(_OBJ_ROLE) if idx.isValid() else None + return obj.get("name") if obj else None + + def apply_theme(self, theme_name=None): + """Re-tint the data-driven bits after a theme change.""" + tok = theme.studio_tokens(theme_name) + self._delegate.set_tokens(tok) + self._search_action.setIcon(icons.tool_icon("search", 16, tok["faint"])) + for dot, key in self._legend_dots: + dot.setStyleSheet(f"background:{tok[key]}; border-radius:4px;") + self.view.viewport().update() + + # --- internals -------------------------------------------------------- + def _on_filter(self, text): + self._proxy.set_needle(text) + self._update_count() + + def _on_current(self, current, _previous): + obj = current.data(_OBJ_ROLE) if current.isValid() else None + if obj: + self.object_activated.emit(obj.get("name", "")) + + def _update_count(self): + self._count.setText(f"{self._proxy.rowCount():,}") diff --git a/PyReconstruct/modules/gui/studio/palette_strip.py b/PyReconstruct/modules/gui/studio/palette_strip.py new file mode 100644 index 00000000..efd1b830 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/palette_strip.py @@ -0,0 +1,130 @@ +"""The trace-palette strip — a real palette across the bottom of the main column. + +A tracked ``PALETTE`` label, a row of 30px data-color swatches (the active one +ringed with a teal outline), a dashed ``+`` add tile, and a right-aligned mono +readout of the active trace (``circle · r=0.045``). The swatch colors are *data* +(trace colors), never chrome. +""" +from PySide6.QtCore import Qt, Signal, QRectF +from PySide6.QtGui import QPainter, QColor, QPen +from PySide6.QtWidgets import ( + QWidget, QHBoxLayout, QLabel, QToolButton, +) + +from ..utils import theme + +_SW = 30 # the swatch itself +_BOX = 34 # widget footprint (leaves room for the offset active outline) + + +class _Swatch(QWidget): + """A single 30px data-color swatch; teal-outlined when active.""" + + clicked = Signal(int) + + def __init__(self, index, color_hex, parent=None): + super().__init__(parent) + self._index = index + self._color = color_hex + self._active = False + self._accent = "#37c0a6" + self.setFixedSize(_BOX, _BOX) + self.setCursor(Qt.PointingHandCursor) + + def set_active(self, on): + self._active = bool(on) + self.update() + + def set_accent(self, accent): + self._accent = accent + self.update() + + def mousePressEvent(self, _event): + self.clicked.emit(self._index) + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + fill = QRectF((_BOX - _SW) / 2, (_BOX - _SW) / 2, _SW, _SW) + p.setPen(QPen(QColor(255, 255, 255, 20), 1)) + p.setBrush(QColor(self._color)) + p.drawRoundedRect(fill, 8, 8) + if self._active: + p.setBrush(Qt.NoBrush) + p.setPen(QPen(QColor(self._accent), 2)) + p.drawRoundedRect(QRectF(1, 1, _BOX - 2, _BOX - 2), 9, 9) + p.end() + + +class PaletteStrip(QWidget): + """Bottom palette strip; emits :attr:`color_selected` / :attr:`add_requested`.""" + + color_selected = Signal(int) + add_requested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioPaletteStrip") + self.setAttribute(Qt.WA_StyledBackground, True) + self._swatches = [] + self._active = 0 + self._accent = theme.studio_tokens()["accent"] + + self._row = QHBoxLayout(self) + self._row.setContentsMargins(13, 7, 13, 7) + self._row.setSpacing(11) + + self._label = QLabel("PALETTE", self) + self._label.setObjectName("studioPaletteLabel") + self._row.addWidget(self._label) + + self._swatch_row = QHBoxLayout() + self._swatch_row.setSpacing(6) + self._row.addLayout(self._swatch_row) + + self._add = QToolButton(self) + self._add.setObjectName("studioAddSwatch") + self._add.setText("+") + self._add.setFixedSize(_SW, _SW) + self._add.setCursor(Qt.PointingHandCursor) + self._add.setFocusPolicy(Qt.NoFocus) + self._add.clicked.connect(self.add_requested) + self._swatch_row.addWidget(self._add) + + self._row.addStretch(1) + + self._readout = QLabel("", self) + self._readout.setObjectName("studioPaletteReadout") + self._row.addWidget(self._readout) + + def set_colors(self, colors): + """Replace the swatch colors (a list of hex strings).""" + for sw in self._swatches: + self._swatch_row.removeWidget(sw) + sw.deleteLater() + self._swatches = [] + for i, color in enumerate(colors): + sw = _Swatch(i, color, self) + sw.set_accent(self._accent) + sw.clicked.connect(self._on_click) + # keep the add tile last + self._swatch_row.insertWidget(self._swatch_row.count() - 1, sw) + self._swatches.append(sw) + self.set_active(min(self._active, len(colors) - 1) if colors else 0) + + def set_active(self, index): + self._active = index + for i, sw in enumerate(self._swatches): + sw.set_active(i == index) + + def set_readout(self, text): + self._readout.setText(text) + + def apply_theme(self, theme_name=None): + self._accent = theme.studio_tokens(theme_name)["accent"] + for sw in self._swatches: + sw.set_accent(self._accent) + + def _on_click(self, index): + self.set_active(index) + self.color_selected.emit(index) diff --git a/PyReconstruct/modules/gui/studio/qss.py b/PyReconstruct/modules/gui/studio/qss.py new file mode 100644 index 00000000..89c07130 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/qss.py @@ -0,0 +1,210 @@ +"""Chrome stylesheet for the Studio layout widgets. + +One source of styling truth for the bespoke widgets, driven entirely by +:func:`PyReconstruct.modules.gui.utils.theme.studio_tokens`. The same template +renders both shells — the Studio (dark) and Atlas (light) tokens differ, the +selectors do not — so a theme toggle is just "re-build and re-apply". + +Set on :class:`~.shell.StudioShell`, it cascades to every child by objectName. +It styles container chrome, labels and buttons (resting + active); the data-ish +bits (object-row swatches, palette swatches, the brand mark, slider tracks, +status dots) are custom-painted in the widgets from the same tokens. + +The template uses ``string.Template`` ``${token}`` placeholders so QSS's literal +``{ }`` braces need no escaping. Active button states use the dynamic ``active`` +property (``QToolButton[active="true"]``); widgets repolish on toggle. +""" +from string import Template + +from ..utils import theme + + +# rem→px uses the mockup's 14px root. Kept inline as literals for legibility. +_TEMPLATE = Template(""" +/* ---- shell ground -------------------------------------------------------- */ +#studioShell, #studioBody, #studioMainColumn, #studioWorkRow { background: ${bg}; } + +/* qdarkstyle paints every QLabel with the theme ground (light in Atlas), which + would show through our dark glass floats; make labels transparent by default + so they take their parent's surface. Labels that need a fill (chip, kbd, + status dots) override this with an id selector or their own stylesheet. */ +QLabel { background: transparent; padding: 0px; margin: 0px; } + +/* ---- title strip --------------------------------------------------------- */ +#studioTitleStrip { + background: ${panel_2}; + border-bottom: 1px solid ${line}; +} +#studioBrand { color: ${ink}; font-weight: 800; font-size: 14px; } +QLabel#studioMenuItem { color: ${muted}; font-size: 12px; padding: 3px 7px; border-radius: 6px; } +QLabel#studioMenuItem:hover { color: ${ink}; background: ${raised}; } +#studioChip { + color: ${chip_ink}; + background: ${chip_bg}; + border: 1px solid ${chip_border}; + border-radius: 10px; + padding: 2px 9px; + font-size: 11px; + font-weight: 600; +} +#studioCmdk { + color: ${faint}; + background: ${bg}; + border: 1px solid ${line}; + border-radius: 7px; + padding: 4px 9px; + font-size: 11px; +} +#studioCmdkKbd { + color: ${muted}; + background: ${raised}; + border: 1px solid ${line}; + border-radius: 4px; + padding: 0px 5px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; + font-size: 10px; +} + +/* ---- activity rail ------------------------------------------------------- */ +#studioActivityRail { + background: ${panel_2}; + border-right: 1px solid ${line}; +} +QToolButton#studioRailButton { + background: transparent; + border: 1px solid transparent; + border-radius: 9px; +} +QToolButton#studioRailButton:hover { background: ${raised}; } +QToolButton#studioRailButton[active="true"] { + background: ${rail_active_bg}; + border: 1px solid ${rail_active_ring}; +} + +/* ---- objects panel ------------------------------------------------------- */ +#studioObjectsPanel { + background: ${panel}; + border-right: 1px solid ${line}; +} +#studioPanelHeader { border-bottom: 1px solid ${line_soft}; } +#studioPanelTitle { + color: ${muted}; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; +} +#studioPanelCount { + color: ${faint}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +QLineEdit#studioFilter { + color: ${ink}; + background: ${bg}; + border: 1px solid ${line}; + border-radius: 8px; + padding: 5px 8px; + font-size: 11px; + selection-background-color: ${accent}; + selection-color: ${on_accent}; +} +QLineEdit#studioFilter:focus { border: 1px solid ${accent}; } +QListView#studioObjectList { + background: ${panel}; + border: none; + outline: none; +} +#studioLegend { border-top: 1px solid ${line_soft}; } +#studioLegendLabel { color: ${faint}; font-size: 10px; } + +/* ---- canvas + glassy floats ---------------------------------------------- */ +#studioCanvas { background: ${canvas_bg}; } +QFrame#studioFloat { + background: ${float_bg}; + border: 1px solid ${float_line}; + border-radius: 10px; +} +#studioFloatText { color: ${float_ink}; font-size: 10px; } +#studioFloatLabel { color: ${float_faint}; font-size: 10px; } +#studioScaleText { + color: ${scale_ink}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +#studioSecnavLabel { + color: ${float_ink}; + font-size: 11px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +#studioSecnavTotal { color: ${float_faint}; } +QToolButton#studioSecnavButton { + color: ${float_muted}; + background: ${raised_dark}; + border: none; + border-radius: 7px; +} +QToolButton#studioSecnavButton:hover { color: ${float_ink}; } + +/* ---- tool rail (flush; no card, no blur, no shadow, no glow) -------------- */ +#studioToolRail { + background: ${panel_2}; + border-left: 1px solid ${line}; +} +QToolButton#studioToolButton { + background: transparent; + border: 1px solid transparent; + border-radius: 10px; +} +QToolButton#studioToolButton:hover { background: ${raised}; } +QToolButton#studioToolButton[active="true"] { + background: ${tool_active_bg}; + border: 1px solid ${tool_active_border}; +} + +/* ---- palette strip ------------------------------------------------------- */ +#studioPaletteStrip { + background: ${panel}; + border-top: 1px solid ${line}; +} +#studioPaletteLabel { + color: ${muted}; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.6px; +} +QToolButton#studioAddSwatch { + color: ${faint}; + background: ${raised}; + border: 1px dashed ${line}; + border-radius: 8px; + font-size: 15px; +} +QToolButton#studioAddSwatch:hover { color: ${ink}; border: 1px dashed ${accent}; } +#studioPaletteReadout { + color: ${faint}; + font-size: 11px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} + +/* ---- status bar ---------------------------------------------------------- */ +#studioStatusBar { + background: ${panel_2}; + border-top: 1px solid ${line}; +} +#studioStatusText { + color: ${muted}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +#studioStatusValue { + color: ${accent}; + font-size: 10px; + font-family: "DejaVu Sans Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +""") + + +def chrome_qss(theme_name=None, app=None) -> str: + """Build the Studio chrome stylesheet for a theme (or the active one).""" + tokens = theme.studio_tokens(theme_name, app) + return _TEMPLATE.substitute(tokens) diff --git a/PyReconstruct/modules/gui/studio/rail.py b/PyReconstruct/modules/gui/studio/rail.py new file mode 100644 index 00000000..57f560ee --- /dev/null +++ b/PyReconstruct/modules/gui/studio/rail.py @@ -0,0 +1,75 @@ +"""The activity rail — the slim left strip that switches the left panel. + +48px wide, ``panel-2`` ground, a column of 34x34 icon tiles (Objects, Traces, +Sections, Flags, 3D scene, then a spacer, then Settings). The active tile is +teal-tinted with an inset teal ring; clicking one emits :attr:`activated` so the +shell can repoint the Objects panel (or launch the 3D panel). +""" +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout + +from ._common import IconTileButton + +#: (key, tooltip, icon_svg_name) in rail order; ``None`` is the flexible spacer +RAIL_ITEMS = [ + ("objects", "Objects", "objects"), + ("traces", "Traces", "traces"), + ("sections", "Sections", "sections"), + ("flags", "Flags", "flag"), + ("scene3d", "3D scene", "scene3d"), + None, + ("settings", "Settings", "settings"), +] + +_TILE_PX = 34 +_ICON_PX = 20 + + +class ActivityRail(QWidget): + """Vertical icon rail; emits :attr:`activated` with the selected item key.""" + + activated = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioActivityRail") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(48) + self._buttons = {} + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(4) + layout.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + + for item in RAIL_ITEMS: + if item is None: + layout.addStretch(1) + continue + key, tip, icon_name = item + btn = IconTileButton(key, icon_name, "studioRailButton", _TILE_PX, _ICON_PX, self) + btn.setToolTip(tip) + btn.clicked.connect(lambda _=False, k=key: self._on_click(k)) + layout.addWidget(btn, 0, Qt.AlignHCenter) + self._buttons[key] = btn + + self.set_active("objects") + + def _on_click(self, key): + self.set_active(key) + self.activated.emit(key) + + def set_active(self, key): + """Light the tile for ``key`` (no-op if unknown); clears the others.""" + if key not in self._buttons: + return + for k, btn in self._buttons.items(): + btn.set_active(k == key) + self._active_key = key + + def active_key(self): + return getattr(self, "_active_key", None) + + def retint(self, rest_color, active_color): + for btn in self._buttons.values(): + btn.retint(rest_color, active_color) diff --git a/PyReconstruct/modules/gui/studio/shell.py b/PyReconstruct/modules/gui/studio/shell.py new file mode 100644 index 00000000..c65a8ad4 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/shell.py @@ -0,0 +1,248 @@ +"""StudioShell — the composite that assembles the Studio layout grammar. + +It lays out the regions exactly as the spec's grammar describes :: + + title strip (full width) + body: [ activity rail | main column ] + main column: [ objects panel | canvas | tool rail ] (the "work row") + [ palette strip ] + status bar (full width) + +and wires enough behavior to be demonstrable: the rail repoints the left panel, +the object list filters and reports selection, selecting an object updates the +in-canvas badge, the section pill steps sections, the tool rail and palette +select, and :meth:`apply_theme` toggles Studio ↔ Atlas (same layout, light/dark +shell, always-dark canvas). + +It is a plain ``QWidget`` so it can be a standalone preview window *or* the main +window's central widget. :meth:`demo` returns one populated with the mockup's +content (including a 3,809-row object list, to prove the list virtualizes). +""" +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QApplication + +from ..utils import theme +from . import qss +from ._common import logo_path +from .title_strip import StudioTitleStrip +from .rail import ActivityRail +from .objects_panel import ObjectsPanel +from .canvas import CanvasArea +from .tool_rail import ToolRail +from .palette_strip import PaletteStrip +from .status_bar import StudioStatusBar + + +class StudioShell(QWidget): + """The whole Studio layout, assembled from the region widgets.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioShell") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setWindowTitle("PyReconstruct") + _logo = logo_path() + if _logo: + self.setWindowIcon(QIcon(_logo)) + self._theme = None + self._section_idx, self._section_total = 148, 287 + self._datasets = {} + self._by_name = {} + + # regions + self.title_strip = StudioTitleStrip(self) + self.rail = ActivityRail(self) + self.objects_panel = ObjectsPanel(self) + self.canvas = CanvasArea(self) + self.tool_rail = ToolRail(self) + self.palette_strip = PaletteStrip(self) + self.status_bar = StudioStatusBar(self) + + # assemble + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self.title_strip) + + body = QWidget(self) + body.setObjectName("studioBody") + body.setAttribute(Qt.WA_StyledBackground, True) + body_l = QHBoxLayout(body) + body_l.setContentsMargins(0, 0, 0, 0) + body_l.setSpacing(0) + body_l.addWidget(self.rail) + + main_col = QWidget(body) + main_col.setObjectName("studioMainColumn") + main_col.setAttribute(Qt.WA_StyledBackground, True) + main_l = QVBoxLayout(main_col) + main_l.setContentsMargins(0, 0, 0, 0) + main_l.setSpacing(0) + + work = QWidget(main_col) + work.setObjectName("studioWorkRow") + work.setAttribute(Qt.WA_StyledBackground, True) + work_l = QHBoxLayout(work) + work_l.setContentsMargins(0, 0, 0, 0) + work_l.setSpacing(0) + work_l.addWidget(self.objects_panel) + work_l.addWidget(self.canvas, 1) + work_l.addWidget(self.tool_rail) + + main_l.addWidget(work, 1) + main_l.addWidget(self.palette_strip) + body_l.addWidget(main_col, 1) + + root.addWidget(body, 1) + root.addWidget(self.status_bar) + + self._wire() + self.apply_theme("studio", app_wide=False) + + # --- wiring ----------------------------------------------------------- + def _wire(self): + self.rail.activated.connect(self._on_rail) + self.objects_panel.object_activated.connect(self._on_object) + self.canvas.section_changed.connect(self._on_section_step) + + def _on_rail(self, key): + # the four list panels repoint the left panel; 3D / Settings are seams + # for follow-up components (the panel stays put, the rail item lights). + if key in self._datasets: + self.objects_panel.set_title(key) + self._set_dataset(key) + + def _on_object(self, name): + status = self._by_name.get(name) + if status: + self.canvas.set_active_object(name, status) + + def _on_section_step(self, delta): + self._section_idx = max(1, min(self._section_total, self._section_idx + delta)) + self.canvas.set_section(self._section_idx, self._section_total) + self.status_bar.set_section(self._section_idx) + + def _set_dataset(self, key): + objs = self._datasets.get(key, []) + self._by_name = {o["name"]: o.get("status") for o in objs} + self.objects_panel.set_objects(objs) + + # --- theming ---------------------------------------------------------- + def apply_theme(self, theme_name=None, app_wide=True): + """Apply Studio (dark) or Atlas (light): same layout, different shell. + + ``app_wide`` also re-applies the qdarkstyle-remap base app stylesheet + (for a standalone preview); the main window owns that when it adopts the + shell, so it can pass ``app_wide=False``. + """ + self._theme = theme_name + if app_wide: + try: + theme.apply_theme(QApplication.instance(), theme_name) + except Exception: + pass + self.setStyleSheet(qss.chrome_qss(theme_name)) + tok = theme.studio_tokens(theme_name) + self.rail.retint(tok["faint"], tok["accent"]) + self.tool_rail.retint(tok["tool_icon_rest"], tok["tool_icon_active"]) + self.objects_panel.apply_theme(theme_name) + self.canvas.apply_theme(theme_name) + self.palette_strip.apply_theme(theme_name) + self.status_bar.apply_theme(theme_name) + self.title_strip.apply_theme(theme_name) + + def current_theme(self): + return self._theme + + # --- demo content ----------------------------------------------------- + @classmethod + def demo(cls, parent=None): + """A shell populated with the mockup's content for preview / tests.""" + shell = cls(parent) + shell._datasets = { + "objects": _demo_objects(), + "traces": _demo_traces(), + "sections": _demo_sections(), + "flags": _demo_flags(), + } + shell.objects_panel.set_title("objects") + shell._set_dataset("objects") + shell.palette_strip.set_colors(_PALETTE) + shell.palette_strip.set_active(0) + shell.palette_strip.set_readout("circle · r=0.045") + shell.canvas.set_section(148, 287) + shell.canvas.set_active_object("spine_042", "review") + shell.canvas.set_brightness(38) + shell.canvas.set_contrast(60) + shell.status_bar.set_section(148) + shell.status_bar.set_alignment("default") + shell.status_bar.set_position("0.8072", "0.8439") + shell.status_bar.set_traces("61,121") + shell.status_bar.set_zoom("240%") + shell.rail.set_active("objects") + shell.tool_rail.set_active("pointer") + return shell + + +# --- demo data (the mockup's content) ---------------------------------------- +#: the mockup's palette swatches (trace colors — data, not chrome) +_PALETTE = ["#6ce0c4", "#f0a6d8", "#9bd35a", "#f4bd4f", + "#5ab0f0", "#b06cf0", "#f07a72", "#37c0a6"] + +#: the eight named rows shown in the mockup, verbatim +_NAMED = [ + {"name": "d001", "type": "dendrite", "color": "#6ce0c4", "status": "curated"}, + {"name": "spine_042", "type": "spine", "color": "#f0a6d8", "status": "review"}, + {"name": "axon_7", "type": "axon", "color": "#9bd35a", "status": "curated"}, + {"name": "psd_018", "type": "synapse", "color": "#f4bd4f", "status": "flagged"}, + {"name": "mito_113", "type": "mitochondrion", "color": "#5ab0f0", "status": "curated"}, + {"name": "autoseg_2207", "type": "", "color": "#b06cf0", "status": "review"}, + {"name": "autoseg_2208", "type": "", "color": "#f07a72", "status": "curated"}, + {"name": "autoseg_2209", "type": "", "color": "#37c0a6", "status": "review"}, +] + + +def _demo_objects(total=3809): + """The eight named rows, then autoseg_NNNN filler up to ``total`` rows. + + The count reads like the mockup's 3,809 and the list is long enough to prove + the model/view only paints the visible rows (virtualization). + """ + objs = [dict(o) for o in _NAMED] + statuses = ("curated", "review", "curated", "flagged", "curated", "review") + start = 2210 + for i in range(total - len(objs)): + objs.append({ + "name": f"autoseg_{start + i}", + "type": "", + "color": _PALETTE[i % len(_PALETTE)], + "status": statuses[i % len(statuses)], + }) + return objs + + +def _demo_traces(): + return [ + {"name": "d001 · c12", "type": "closed", "color": "#6ce0c4", "status": "curated"}, + {"name": "spine_042 · c3", "type": "closed", "color": "#f0a6d8", "status": "review"}, + {"name": "axon_7 · c44", "type": "open", "color": "#9bd35a", "status": "curated"}, + {"name": "psd_018 · c1", "type": "closed", "color": "#f4bd4f", "status": "flagged"}, + {"name": "mito_113 · c8", "type": "closed", "color": "#5ab0f0", "status": "curated"}, + ] + + +def _demo_sections(): + out = [] + for n in range(146, 152): + out.append({"name": f"section {n}", "type": "0.004 µm/px", + "color": "#5ab0f0", "status": "curated"}) + return out + + +def _demo_flags(): + return [ + {"name": "check merge", "type": "psd_018", "color": "#f07a72", "status": "flagged"}, + {"name": "verify split", "type": "autoseg_2207", "color": "#f4bd4f", "status": "review"}, + {"name": "re-trace", "type": "spine_042", "color": "#f4bd4f", "status": "review"}, + ] diff --git a/PyReconstruct/modules/gui/studio/status_bar.py b/PyReconstruct/modules/gui/studio/status_bar.py new file mode 100644 index 00000000..6cbe8ecf --- /dev/null +++ b/PyReconstruct/modules/gui/studio/status_bar.py @@ -0,0 +1,81 @@ +"""The status bar — a slim mono strip of live readouts. + +30px, ``panel-2`` ground, monospace. Section / alignment / cursor x,y on the +left; trace count and zoom on the right. The active *values* are teal; their +labels are muted. +""" +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel + +from ..utils import theme + + +class StudioStatusBar(QWidget): + """Mono status strip; set fields via the ``set_*`` methods.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioStatusBar") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedHeight(30) + self._tok = theme.studio_tokens() + self._fields = { + "section": "148", "alignment": "default", + "x": "0.8072", "y": "0.8439", + "traces": "61,121", "zoom": "240%", + } + + lay = QHBoxLayout(self) + lay.setContentsMargins(13, 0, 13, 0) + lay.setSpacing(17) + self._section = self._seg(lay) + self._alignment = self._seg(lay) + self._xy = self._seg(lay) + lay.addStretch(1) + self._traces = self._seg(lay) + self._zoom = self._seg(lay) + self._refresh() + + def _seg(self, layout): + lab = QLabel(self) + lab.setObjectName("studioStatusText") + lab.setTextFormat(Qt.RichText) + layout.addWidget(lab) + return lab + + # --- public API ------------------------------------------------------- + def set_section(self, value): + self._fields["section"] = str(value) + self._refresh() + + def set_alignment(self, value): + self._fields["alignment"] = str(value) + self._refresh() + + def set_position(self, x, y): + self._fields["x"], self._fields["y"] = str(x), str(y) + self._refresh() + + def set_traces(self, value): + self._fields["traces"] = str(value) + self._refresh() + + def set_zoom(self, value): + self._fields["zoom"] = str(value) + self._refresh() + + def apply_theme(self, theme_name=None): + self._tok = theme.studio_tokens(theme_name) + self._refresh() + + # --- internals -------------------------------------------------------- + def _v(self, value): + return f"{value}" + + def _refresh(self): + f = self._fields + self._section.setText(f"section {self._v(f['section'])}") + self._alignment.setText(f"alignment {self._v(f['alignment'])}") + self._xy.setText(f"x {self._v(f['x'])} y {self._v(f['y'])}") + self._traces.setText(f"{self._v(f['traces'])} traces") + self._zoom.setText(f"zoom {self._v(f['zoom'])}") diff --git a/PyReconstruct/modules/gui/studio/title_strip.py b/PyReconstruct/modules/gui/studio/title_strip.py new file mode 100644 index 00000000..3c3a9c43 --- /dev/null +++ b/PyReconstruct/modules/gui/studio/title_strip.py @@ -0,0 +1,119 @@ +"""The title strip — brand, menu names, alignment chip, and the ⌘K field. + +40px, ``panel-2`` ground. A conic-gradient brand mark + "Py**Reconstruct**" +(with "Reconstruct" in teal), the familiar menu names, and on the right an +"Alignment · default" chip plus a ⌘K "Search actions" field. + +This is a representative strip for the shell and the sign-off preview — the real +main window keeps its native ``QMenuBar``; the strip exists so the Studio layout +reads as a whole. The ⌘K field's *affordance* is in scope; its full +command-palette behaviour is a follow-up. +""" +from PySide6.QtCore import Qt, QRectF +from PySide6.QtGui import QPainter, QConicalGradient, QColor, QPixmap +from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QFrame + +from ..utils import theme +from ._common import logo_path + +_MENU = ["File", "Edit", "Series", "Section", "Lists", + "Alignments", "Autosegment", "View", "Help"] +_DOTS = ("#f0655a", "#f4bd4f", "#3ecf8e") # window controls (decorative) + + +class _BrandMark(QWidget): + """The app mark — the bare (transparent) fork logo. + + The flat mark reads on both the dark and light title bars, so it needs no + per-theme variant. Falls back to the conic-gradient placeholder if the asset + can't be loaded, so the strip never renders empty. + """ + + def __init__(self, size=22, parent=None): + super().__init__(parent) + self._size = size + self.setFixedSize(size, size) + path = logo_path() + pm = QPixmap(path) if path else QPixmap() + self._pixmap = pm if not pm.isNull() else None + + def paintEvent(self, _event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + p.setRenderHint(QPainter.SmoothPixmapTransform, True) + r = self.rect() + if self._pixmap is not None: + p.drawPixmap(r, self._pixmap) + else: + g = QConicalGradient(r.center().x(), r.center().y(), 200) + for pos, col in ((0.0, "#37c0a6"), (0.25, "#5ab0f0"), (0.5, "#b06cf0"), + (0.75, "#f4bd4f"), (1.0, "#37c0a6")): + g.setColorAt(pos, QColor(col)) + p.setPen(Qt.NoPen) + p.setBrush(g) + p.drawRoundedRect(QRectF(r), 5, 5) + p.end() + + +class _Dot(QLabel): + def __init__(self, color, parent=None): + super().__init__(parent) + self.setFixedSize(11, 11) + self.setStyleSheet(f"background:{color}; border-radius:5px;") + + +class StudioTitleStrip(QWidget): + """Brand + menu + alignment chip + ⌘K search field.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioTitleStrip") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedHeight(40) + + lay = QHBoxLayout(self) + lay.setContentsMargins(11, 0, 11, 0) + lay.setSpacing(7) + + for c in _DOTS: + lay.addWidget(_Dot(c, self)) + + lay.addSpacing(4) + self._brand = _BrandMark(22, self) + lay.addWidget(self._brand) + # one label for the whole wordmark so "Py" and "Reconstruct" are a single + # word (no layout gap between them); "Reconstruct" takes the teal accent. + self._brand_label = QLabel(self) + self._brand_label.setObjectName("studioBrand") + self._brand_label.setTextFormat(Qt.RichText) + self._brand_label.setText('PyReconstruct') + lay.addWidget(self._brand_label) + + lay.addSpacing(12) + for name in _MENU: + item = QLabel(name, self) + item.setObjectName("studioMenuItem") + lay.addWidget(item) + + lay.addStretch(1) + + chip = QLabel("Alignment · default", self) + chip.setObjectName("studioChip") + lay.addWidget(chip) + + cmdk = QFrame(self) + cmdk.setObjectName("studioCmdk") + cmdk.setAttribute(Qt.WA_StyledBackground, True) + cl = QHBoxLayout(cmdk) + cl.setContentsMargins(9, 4, 7, 4) + cl.setSpacing(8) + cl.addWidget(QLabel("Search actions", cmdk)) + kbd = QLabel("⌘K", cmdk) + kbd.setObjectName("studioCmdkKbd") + cl.addWidget(kbd) + lay.addWidget(cmdk) + + def apply_theme(self, theme_name=None): + """Re-tint the wordmark's accent to the active theme (mark is theme-free).""" + accent = theme.studio_tokens(theme_name)["accent"] + self._brand_label.setText(f'PyReconstruct') diff --git a/PyReconstruct/modules/gui/studio/tool_rail.py b/PyReconstruct/modules/gui/studio/tool_rail.py new file mode 100644 index 00000000..e9d4e72c --- /dev/null +++ b/PyReconstruct/modules/gui/studio/tool_rail.py @@ -0,0 +1,81 @@ +"""The tool rail — the flush right strip of drawing tools. + +60px wide, ``panel-2`` ground, a hairline border-left, and 38x38 tiles with +muted icons. Deliberately **flush-docked**: not a floating card — no blur, no +shadow, no "TOOLS" header, no keycap badges, no glow. The active tool reads as +"lit from within" (a subtle gradient tile + teal icon + teal border), nothing +more. A small gap separates the drawing tools from Flag / Host. +""" +from PySide6.QtCore import Signal, Qt +from PySide6.QtWidgets import QWidget, QVBoxLayout + +from ._common import IconTileButton + +#: (key, tooltip, icon_svg_name); ``None`` is the fixed gap between groups +TOOL_ITEMS = [ + ("pointer", "Pointer", "pointer"), + ("panzoom", "Pan / zoom", "panzoom"), + ("closedtrace", "Closed trace", "closedtrace"), + ("opentrace", "Open trace", "opentrace"), + ("knife", "Knife", "knife"), + ("stamp", "Stamp", "stamp"), + ("grid", "Grid", "grid"), + None, + ("flag", "Flag", "flag"), + ("host", "Host", "host"), +] + +_TILE_PX = 38 +_ICON_PX = 22 +_GAP_PX = 7 + + +class ToolRail(QWidget): + """Vertical tool rail; emits :attr:`tool_selected` with the active tool key.""" + + tool_selected = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("studioToolRail") + self.setAttribute(Qt.WA_StyledBackground, True) + self.setFixedWidth(60) + self._buttons = {} + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(5) + layout.setAlignment(Qt.AlignHCenter | Qt.AlignTop) + + for item in TOOL_ITEMS: + if item is None: + layout.addSpacing(_GAP_PX) + continue + key, tip, icon_name = item + btn = IconTileButton(key, icon_name, "studioToolButton", _TILE_PX, _ICON_PX, self) + btn.setToolTip(tip) + btn.clicked.connect(lambda _=False, k=key: self._on_click(k)) + layout.addWidget(btn, 0, Qt.AlignHCenter) + self._buttons[key] = btn + + layout.addStretch(1) + self.set_active("pointer") + + def _on_click(self, key): + self.set_active(key) + self.tool_selected.emit(key) + + def set_active(self, key): + """Light the tile for ``key`` (no-op if unknown); clears the others.""" + if key not in self._buttons: + return + for k, btn in self._buttons.items(): + btn.set_active(k == key) + self._active_key = key + + def active_key(self): + return getattr(self, "_active_key", None) + + def retint(self, rest_color, active_color): + for btn in self._buttons.values(): + btn.retint(rest_color, active_color) diff --git a/PyReconstruct/modules/gui/utils/icon_svgs.py b/PyReconstruct/modules/gui/utils/icon_svgs.py index 2c12ad76..8b03120a 100644 --- a/PyReconstruct/modules/gui/utils/icon_svgs.py +++ b/PyReconstruct/modules/gui/utils/icon_svgs.py @@ -99,4 +99,74 @@ def _svg(body: str) -> bytes: '' ), + + # --- Studio layout chrome icons ----------------------------------------- + # Same 24x24 stroked grid + solid-black artwork as the tools above, so they + # tint through the identical render_svg_tinted path. The activity rail and + # the tool rail's Flag/Host tiles reference these (replacing the mockup's + # placeholder unicode glyphs ▣ ⤳ ▤ ⚑ ◈ ⚙ ⊹). + + # activity rail · Objects — a solid segmented-object silhouette + "objects": _svg( + '' + ), + # activity rail · Traces — a freehand path with two vertices + "traces": _svg( + '' + '' + '' + ), + # activity rail · Sections — an isometric z-stack of section planes + "sections": _svg( + '' + '' + '' + ), + # activity rail · 3D scene — a cube + "scene3d": _svg( + '' + '' + ), + # activity rail · Settings — a gear + "settings": _svg( + '' + '' + ), + # tool rail / activity rail · Flag — flag on a pole + "flag": _svg( + '' + '' + ), + # tool rail · Host — a host→hosted relationship (two linked nodes) + "host": _svg( + '' + '' + '' + ), + # Objects-panel filter field · Search — a magnifier + "search": _svg( + '' + '' + ), + # section-nav pill chevrons + "chevron_left": _svg( + '' + ), + "chevron_right": _svg( + '' + ), } diff --git a/PyReconstruct/modules/gui/utils/theme.py b/PyReconstruct/modules/gui/utils/theme.py index 77a3a7c7..c43ab043 100644 --- a/PyReconstruct/modules/gui/utils/theme.py +++ b/PyReconstruct/modules/gui/utils/theme.py @@ -1,23 +1,48 @@ """Application theme engine. Centralizes PyReconstruct's UI chrome. The theme **defaults to the OS color -scheme** (light/dark) and can be overridden to System / Light / Dark; the choice -is persisted app-wide via QSettings. Dark keeps the established ``qdarkstyle`` -look (its ``DarkPalette``); light is ``qdarkstyle``'s clean ``LightPalette`` -counterpart. A small token palette (charcoal ground, calm azure accent) is -layered on top of both. +scheme** (light/dark) and can be overridden to one of several named themes; the +choice is persisted app-wide via QSettings. + +Themes (the values a user can pick and that get persisted): + + * ``system`` — follow the OS light/dark scheme (the default). + * ``light`` / ``dark`` — qdarkstyle's clean ``LightPalette`` / established + ``DarkPalette`` look, with a calm azure accent layered on selection chrome. + * ``studio`` — the "Studio" concept: a cool, dark, scientific base + (charcoal ``#0c0f14`` ground) with a calm **teal** accent (``#37c0a6``). + * ``atlas`` — the "Atlas" concept: Studio's light sibling — a cool, neutral + light shell for bright rooms / brightfield work, same teal accent. + +``studio`` and ``atlas`` are built on qdarkstyle's Dark / Light base (for full, +consistent widget coverage) and then *remapped*: every qdarkstyle palette hex in +the generated stylesheet is substituted for its Studio / Atlas counterpart in a +single pass. The qdarkstyle palette constants are read at run time, so the remap +tracks the installed qdarkstyle version rather than hard-coding its hues. + +On top of that base, the bespoke **Studio layout** widgets (activity rail, +Objects panel, glassy canvas overlays, tool rail, palette strip, status bar) are +styled from an explicit chrome-token table, :func:`studio_tokens`. That table is +the single source of truth for those widgets' QSS and custom painting. Two rules +shape it: there is exactly one interactive accent (teal ``#37c0a6``), and the EM +canvas — and therefore the glassy controls floating over it — stays dark in both +Studio and Atlas (EM imagery belongs on dark). Trace and segmentation colors are *data* (drawn in the field, not via QSS) and -are deliberately never touched here. +are deliberately never touched by the chrome. The Studio mockup's curation hues +(curated / review / flagged) are surfaced as small status indicators by the +layout widgets, but are likewise fixed data hues, not theme chrome. The persisted preference shares the global ``QSettings`` ``"theme"`` key with ``Series.getOption``/``setOption`` (that option already resolves to the global ``QSettings("KHLab", "PyReconstruct")`` store), so the two stay in sync. Qt/qdarkstyle are imported lazily inside the functions that need them so the -pure mode-resolution logic (:func:`normalize_mode`, :func:`resolve_scheme`) -stays importable and unit-testable without a Qt/qdarkstyle install. +pure resolution logic (:func:`normalize_mode`, :func:`resolve_theme`, +:func:`resolve_scheme`) stays importable and unit-testable without a +Qt/qdarkstyle install. """ +import re # --- persisted preference (shared with Series.getOption/setOption) ----------- QSETTINGS_ORG = "KHLab" @@ -25,11 +50,21 @@ THEME_KEY = "theme" #: the modes a user can choose; also the persisted values -MODES = ("system", "light", "dark") +MODES = ("system", "light", "dark", "studio", "atlas") -#: the two concrete schemes the chrome can resolve to +#: the concrete themes a stylesheet can be built for (``system`` resolves to one +#: of these). ``light``/``dark`` are the qdarkstyle base looks; ``studio`` and +#: ``atlas`` are the named concept palettes. +THEMES = ("light", "dark", "studio", "atlas") + +#: the two color *families*. Every concrete theme belongs to one; the family +#: drives anything that only cares about light-vs-dark (the app icon glyph, the +#: monochrome tool-icon tint, qdarkstyle's base palette). SCHEMES = ("light", "dark") +#: which family each concrete theme belongs to +_FAMILY = {"light": "light", "dark": "dark", "studio": "dark", "atlas": "light"} + #: When the OS scheme is unknowable (Qt < 6.8 on Linux without a desktop #: portal reports ``ColorScheme.Unknown``, and offscreen always does), fall #: back to dark. This matches the shipped app-icon logic (Unknown -> dark @@ -38,21 +73,172 @@ UNKNOWN_FALLBACK = "dark" # --- token palette ----------------------------------------------------------- -# Provisional hues: the engine and persistence are headless-verified, but the -# exact look (and the accent's specificity vs qdarkstyle's own selection color) -# is signed off interactively. Tune these constants, not the wiring. -ACCENT = "#3d8bd4" # calm azure — selection/highlight accent +# Azure accent for the qdarkstyle-based light/dark themes. (Provisional hues: +# the engine and persistence are headless-verified; the exact look is signed off +# interactively. Tune these constants, not the wiring.) +ACCENT = "#3d8bd4" # calm azure — selection/highlight accent (light/dark) ACCENT_TEXT = "#ffffff" # text drawn on the accent GROUND_DARK = "#19232d" # qdarkstyle DarkPalette ground (informational) GROUND_LIGHT = "#fafafa" # qdarkstyle LightPalette ground (informational) -# Tool-button icon color (the monochrome line icons tint to this per scheme). +# Tool-button icon color (the monochrome line icons tint to this per theme). # Provisional — from the v1 prototype's resting tool color (its --txt-dim). -ICON_DARK = "#9aa7bb" # icons on dark chrome -ICON_LIGHT = "#54627a" # icons on light chrome - - -# --- pure mode/scheme logic (no Qt) ------------------------------------------ +ICON_DARK = "#9aa7bb" # icons on dark (qdarkstyle) chrome +ICON_LIGHT = "#54627a" # icons on light (qdarkstyle) chrome + +# --- named-theme palettes (the "Studio" / "Atlas" concepts) ------------------ +# Studio's palette is lifted verbatim from the UI-direction mockup +# (artifact a386d6e8 — "Direction A: Studio"). Atlas is that mockup's described +# light sibling ("Direction B: Atlas — light precision, same teal accent"); the +# mockup names it but gives no hex, so its neutral cool-light shell is derived +# here to mirror Studio while keeping the identical teal/cyan accent. +STUDIO_ACCENT = "#37c0a6" # teal — Studio/Atlas selection/highlight accent +STUDIO_GROUND = "#0c0f14" # cool charcoal ground (informational) +ATLAS_GROUND = "#f4f6fa" # cool near-white ground (informational) + +#: Studio: remap qdarkstyle ``DarkPalette`` -> Studio hues. Keys are qdarkstyle +#: palette-constant *names* (resolved to their hexes at run time); values are the +#: Studio targets. Background ramp runs darkest (BACKGROUND_1, the ground) to +#: lightest (BACKGROUND_6, handles); the accent ramp lands the true teal on +#: ACCENT_3 (qdarkstyle's focus/hover accent). +_STUDIO_MAP = { + "COLOR_BACKGROUND_1": "#0c0f14", # ground + "COLOR_BACKGROUND_2": "#141921", # panel + "COLOR_BACKGROUND_3": "#1b212b", # panel-2 / low raise + "COLOR_BACKGROUND_4": "#2a323e", # hairline / border (dominant) + "COLOR_BACKGROUND_5": "#3a4452", # hover / handle + "COLOR_BACKGROUND_6": "#4a5666", # lightest handle / scrollbar + "COLOR_TEXT_1": "#e8edf3", # ink + "COLOR_TEXT_2": "#aab4c0", + "COLOR_TEXT_3": "#9aa7b5", # muted + "COLOR_TEXT_4": "#6a7686", # faint (mockup --faint) + "COLOR_DISABLED": "#4f5a6b", + "COLOR_ACCENT_1": "#16332d", # darkest teal (pressed/selected base) + "COLOR_ACCENT_2": "#2b8675", # mid teal + "COLOR_ACCENT_3": "#37c0a6", # the teal accent (focus/hover) + "COLOR_ACCENT_4": "#45cdb2", + "COLOR_ACCENT_5": "#5ad8c0", # brightest teal +} + +#: Atlas: remap qdarkstyle ``LightPalette`` -> Atlas hues. Background ramp runs +#: lightest (BACKGROUND_1, the ground) to darkest (BACKGROUND_6, borders); the +#: teal accent lands on ACCENT_4 (qdarkstyle light's focus accent). +_ATLAS_MAP = { + "COLOR_BACKGROUND_1": "#f4f6fa", # cool light ground / surface + "COLOR_BACKGROUND_2": "#e9edf3", # alt rows / headers + "COLOR_BACKGROUND_3": "#dde3ec", + "COLOR_BACKGROUND_4": "#ccd4df", # hairline / border (dominant) + "COLOR_BACKGROUND_5": "#bcc5d2", + "COLOR_BACKGROUND_6": "#aab4c2", + "COLOR_TEXT_1": "#1a2230", # cool near-black ink + "COLOR_TEXT_2": "#33405a", + "COLOR_TEXT_3": "#586478", # muted + "COLOR_TEXT_4": "#8893a6", # faint + "COLOR_DISABLED": "#aab3bf", + "COLOR_ACCENT_1": "#d9f1ea", # light teal tint (hover/selection wash) + "COLOR_ACCENT_2": "#88dccc", + "COLOR_ACCENT_3": "#4cccb5", + "COLOR_ACCENT_4": "#37c0a6", # the teal accent + "COLOR_ACCENT_5": "#2ba892", # deeper teal (pressed) +} + +_THEME_REMAP = {"studio": _STUDIO_MAP, "atlas": _ATLAS_MAP} + +#: accent each concrete theme paints onto selection chrome / the active tool +_THEME_ACCENT = { + "light": ACCENT, "dark": ACCENT, + "studio": STUDIO_ACCENT, "atlas": STUDIO_ACCENT, +} + +#: color drawn *on* the accent (selected-row text, highlighted menu items, the +#: active-tool glyph). White reads on the azure light/dark accent, but the +#: brighter Studio/Atlas teal needs a dark ink — white on ``#37c0a6`` is only +#: ~2.3:1 (below WCAG's 3:1 floor), whereas the charcoal ground clears 8:1. +_THEME_ON_ACCENT = { + "light": ACCENT_TEXT, "dark": ACCENT_TEXT, + "studio": STUDIO_GROUND, "atlas": STUDIO_GROUND, +} + +#: resting tool-icon tint per theme (active tool uses :func:`accent_text`) +_THEME_ICON = { + "light": ICON_LIGHT, "dark": ICON_DARK, + "studio": "#9aa7b5", "atlas": "#586478", +} + +# --- Studio-layout chrome tokens --------------------------------------------- +# The bespoke layout widgets paint from these explicit tokens (not qdarkstyle). +# Two families: a Studio (dark) shell and an Atlas (light) shell. The canvas and +# the glassy controls floating over it are identical in both — the EM imagery is +# always on dark — so those live in ``_CANVAS_TOKENS`` and merge into each shell. +# +# Color formats are Qt-stylesheet-ready: plain ``#rrggbb`` hexes, ``rgba(...)`` +# where alpha is needed (Qt QSS does NOT accept ``#rrggbbaa``), and +# ``qlineargradient(...)`` strings for the lit-from-within active states. + +#: shared dark canvas + glassy floating controls (both Studio and Atlas) +_CANVAS_TOKENS = { + "canvas_bg": "#0c0f14", # EM ground (always dark) + "canvas_bg_2": "#0e1218", # subtle checker alt for the canvas + "float_bg": "rgba(17, 22, 29, 0.80)", # glassy card (#11161d @ .8 == cc) + "float_line": "#2a323e", + "float_ink": "#e8edf3", + "float_muted": "#9aa7b5", + "float_faint": "#6a7686", + "scale_bar": "#e8edf3", # the bright scale bar itself + "scale_ink": "#cdd6df", # its dimmer mono label (spec mockup) + "raised_dark": "#222a35", # secnav buttons / B&C tracks + # curation status hues (DATA, fixed in both themes) + "ok": "#3ecf8e", "warn": "#f4bd4f", "bad": "#f07a72", + # the one accent + the rare secondary, and the dark ink drawn on solid teal + "accent": "#37c0a6", "accent_2": "#5ab0f0", "on_accent": "#0c0f14", +} + +#: Studio (dark) shell +_STUDIO_TOKENS = { + "family": "dark", + "bg": "#0c0f14", "panel": "#141921", "panel_2": "#1b212b", "raised": "#222a35", + "line": "#2a323e", "line_soft": "#212833", + "ink": "#e8edf3", "muted": "#9aa7b5", "faint": "#6a7686", + # activity-rail active item: lit-from-within wash + inset teal ring + "rail_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #22404a, stop:1 #1a2a33)", + "rail_active_ring": "#2c5a5a", + # tool-rail active tile: lit gradient + teal border, NO glow + "tool_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #243a44, stop:1 #1a2730)", + "tool_active_border": "#2c5a5a", + "tool_icon_rest": "#9aa7b5", "tool_icon_active": "#37c0a6", + # selected object row: teal left bar + a wash that fades to nothing + "row_sel_a": "#1a2c33", "row_sel_b": "rgba(26, 44, 51, 0)", "row_sel_bar": "#37c0a6", + # alignment chip (titlebar) + "chip_bg": "#15324a", "chip_ink": "#9ed2ff", "chip_border": "#1e466b", +} +_STUDIO_TOKENS.update(_CANVAS_TOKENS) + +#: Atlas (light) shell — same layout + same teal, light neutrals; dark canvas +_ATLAS_TOKENS = { + "family": "light", + "bg": "#f4f6fa", "panel": "#e9edf3", "panel_2": "#dde3ec", "raised": "#d2dae5", + "line": "#ccd4df", "line_soft": "#dde3ec", + "ink": "#1a2230", "muted": "#586478", "faint": "#8893a6", + "rail_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #dff3ee, stop:1 #cdebe1)", + "rail_active_ring": "#7fcbb9", + "tool_active_bg": + "qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #dff3ee, stop:1 #cdebe1)", + "tool_active_border": "#7fcbb9", + "tool_icon_rest": "#586478", "tool_icon_active": "#37c0a6", + "row_sel_a": "#d9f1ea", "row_sel_b": "rgba(217, 241, 234, 0)", "row_sel_bar": "#37c0a6", + "chip_bg": "#dbeefe", "chip_ink": "#1d5a86", "chip_border": "#b6dcf5", +} +_ATLAS_TOKENS.update(_CANVAS_TOKENS) + +#: chrome tokens by family (the Studio layout is always teal-accented; the +#: theme toggle only swaps the light-vs-dark *shell* around the dark canvas) +_LAYOUT_TOKENS = {"dark": _STUDIO_TOKENS, "light": _ATLAS_TOKENS} + + +# --- pure mode/theme/scheme logic (no Qt) ------------------------------------ def normalize_mode(value) -> str: """Coerce any stored/legacy theme value to one of :data:`MODES`. @@ -69,8 +255,8 @@ def normalize_mode(value) -> str: return "system" -def resolve_scheme(mode, system_is_light) -> str: - """Resolve a mode to a concrete scheme (``"light"`` / ``"dark"``). +def resolve_theme(mode, system_is_light) -> str: + """Resolve a mode to a concrete theme (one of :data:`THEMES`). Pure and Qt-free so the full matrix is unit-testable. @@ -79,11 +265,9 @@ def resolve_scheme(mode, system_is_light) -> str: system_is_light: ``True`` if the OS scheme is light, ``False`` if dark, ``None`` if unknown. """ - if mode == "light": - return "light" - if mode == "dark": - return "dark" - # system + if mode in THEMES: # light, dark, studio, atlas + return mode + # system: follow the OS if system_is_light is True: return "light" if system_is_light is False: @@ -91,6 +275,15 @@ def resolve_scheme(mode, system_is_light) -> str: return UNKNOWN_FALLBACK +def resolve_scheme(mode, system_is_light) -> str: + """Resolve a mode to a concrete color *family* (``"light"`` / ``"dark"``). + + Studio resolves to the dark family, Atlas to light. Used by anything that + only distinguishes light vs dark (the app-icon glyph, the tool-icon tint). + """ + return _FAMILY[resolve_theme(mode, system_is_light)] + + # --- Qt-facing helpers (lazy imports) ---------------------------------------- def _app(app=None): if app is not None: @@ -129,78 +322,172 @@ def write_mode(mode) -> str: return mode -def current_scheme(app=None, mode=None) -> str: - """The concrete scheme currently in effect (respects the manual override).""" +def current_theme(app=None, mode=None) -> str: + """The concrete theme currently in effect (respects the manual override).""" if mode is None: mode = read_mode() - return resolve_scheme(normalize_mode(mode), system_is_light(app)) + return resolve_theme(normalize_mode(mode), system_is_light(app)) -def icon_color(scheme=None, app=None) -> str: - """Hex color the monochrome tool icons should tint to for a scheme. +def current_scheme(app=None, mode=None) -> str: + """The concrete color family (light/dark) currently in effect.""" + return _FAMILY[current_theme(app, mode)] + - Defaults to the scheme currently in effect. +def accent_color(theme=None, app=None) -> str: + """Accent hex the active theme paints onto selection chrome / active tool. + + Azure for the qdarkstyle light/dark themes, teal for Studio/Atlas. Defaults + to the theme currently in effect. """ - if scheme is None: - scheme = current_scheme(app) - return ICON_DARK if scheme == "dark" else ICON_LIGHT + if theme is None: + theme = current_theme(app) + return _THEME_ACCENT.get(theme, ACCENT) + + +def accent_text(theme=None, app=None) -> str: + """Hex for text/icons drawn on the accent. + + White on the azure (light/dark) accent; a dark ink on the brighter + Studio/Atlas teal so a selected row's text and the active-tool glyph stay + legible. Defaults to the theme currently in effect. + """ + if theme is None: + theme = current_theme(app) + return _THEME_ON_ACCENT.get(theme, ACCENT_TEXT) + + +def icon_color(theme=None, app=None) -> str: + """Hex the monochrome tool icons should tint to for a theme. + + Accepts a concrete theme or a family (``"light"``/``"dark"``); defaults to + the theme currently in effect. + """ + if theme is None: + theme = current_theme(app) + if theme in _THEME_ICON: + return _THEME_ICON[theme] + # tolerate a family string that isn't a concrete theme + return ICON_DARK if _FAMILY.get(theme, "dark") == "dark" else ICON_LIGHT + + +def studio_tokens(theme=None, app=None) -> dict: + """Chrome-token table for the bespoke Studio layout widgets. + + Returns a flat dict of Qt-stylesheet-ready color tokens (see the + ``_*_TOKENS`` tables above) for the theme currently in effect, or for an + explicitly named theme/family. The Studio layout is always teal-accented; + the toggle only swaps the light-vs-dark *shell* around an always-dark + canvas, so the returned table is selected purely by color family — Studio + and ``dark`` share the dark shell, Atlas and ``light`` the light shell. + + The returned dict is a fresh copy; callers may mutate it freely. + """ + if theme is None: + theme = current_theme(app) + family = _FAMILY.get(theme, theme if theme in SCHEMES else "dark") + return dict(_LAYOUT_TOKENS[family]) # --- stylesheet construction ------------------------------------------------- -# Applies to both schemes. The first two rules are the long-standing qdarkstyle -# fix-ups (transparent QPushButton border; QComboBox dropdown padding), now -# shared by light too. The rest applies the azure accent to selection chrome. -def _addon_qss() -> str: +# The accent add-on applies to every theme. The first two rules are the +# long-standing qdarkstyle fix-ups (transparent QPushButton border; QComboBox +# dropdown padding); the rest paints the theme accent onto selection chrome. +def _accent_addon_qss(accent: str, on_accent: str) -> str: return f""" QPushButton {{ border: 1px solid transparent; }} QComboBox {{ padding-right: 40px; }} QListView, QTreeView, QTableView, QListWidget, QTreeWidget, QTableWidget {{ - selection-background-color: {ACCENT}; - selection-color: {ACCENT_TEXT}; + selection-background-color: {accent}; + selection-color: {on_accent}; }} -QMenu::item:selected {{ background-color: {ACCENT}; color: {ACCENT_TEXT}; }} +QMenu::item:selected {{ background-color: {accent}; color: {on_accent}; }} """ -def build_stylesheet(scheme: str) -> str: - """Build the full app stylesheet for a scheme (qdarkstyle base + tokens). +def _addon_qss() -> str: + """The azure add-on for the light/dark themes (unchanged historical look).""" + return _accent_addon_qss(ACCENT, ACCENT_TEXT) + + +def _remap_palette(qss: str, base_palette, name_to_target: dict) -> str: + """Substitute a qdarkstyle base stylesheet's palette hexes for new ones. + + ``name_to_target`` maps qdarkstyle palette-constant *names* (e.g. + ``COLOR_BACKGROUND_1``) to target hexes. The source hex is read from + ``base_palette`` at run time, so the remap follows the installed qdarkstyle + version. Replacement is a single case-insensitive pass over 6-digit hex + tokens (the generated QSS uses no ``rgba()``), so a target that happens to + equal another source is never re-substituted. The trailing boundary keeps a + longer token (a hypothetical 8-digit ``#rrggbbaa``) from being partially + matched and corrupted. + """ + mapping = {} + for cname, target in name_to_target.items(): + src = getattr(base_palette, cname, None) + if src: + mapping[src.lower()] = target + if not mapping: + return qss + return re.sub( + r"#[0-9a-fA-F]{6}(?![0-9a-fA-F])", + lambda m: mapping.get(m.group(0).lower(), m.group(0)), + qss, + ) + + +def build_stylesheet(theme: str) -> str: + """Build the full app stylesheet for a concrete theme. + + ``light``/``dark`` are qdarkstyle's base look + the azure add-on (unchanged). + ``studio``/``atlas`` take the matching qdarkstyle base (Dark / Light), remap + its palette to the Studio / Atlas hues, then add the teal accent. Accepts a + family string (``"light"``/``"dark"``) as a tolerant alias. Raises ``ImportError`` if qdarkstyle is unavailable; callers decide whether to fall back to native styling. """ import qdarkstyle from qdarkstyle import DarkPalette, LightPalette - palette = DarkPalette if scheme == "dark" else LightPalette - base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=palette) + + if theme not in THEMES: + theme = resolve_scheme(theme, None) # tolerate a family/legacy string + base_palette = DarkPalette if _FAMILY[theme] == "dark" else LightPalette + base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=base_palette) + + remap = _THEME_REMAP.get(theme) + if remap is not None: + base = _remap_palette(base, base_palette, remap) + return base + _accent_addon_qss(_THEME_ACCENT[theme], _THEME_ON_ACCENT[theme]) return base + _addon_qss() def apply_theme(app=None, mode=None) -> str: """Resolve ``mode`` and apply the matching stylesheet app-wide. - Returns the concrete scheme applied. If qdarkstyle can't be imported, falls + Returns the concrete theme applied. If qdarkstyle can't be imported, falls back to native styling (empty stylesheet + standard palette) rather than raising, so startup never breaks on a missing optional dependency. """ app = _app(app) - scheme = current_scheme(app, mode) + theme = current_theme(app, mode) try: - qss = build_stylesheet(scheme) + qss = build_stylesheet(theme) except Exception: qss = "" app.setStyleSheet(qss) if not qss: app.setPalette(app.style().standardPalette()) - return scheme + return theme def qss_active(app=None) -> bool: """True when a (qdarkstyle-based) app stylesheet is in effect. Used by widgets that need to compensate qdarkstyle's metrics (e.g. table - column widths), which now applies to both light and dark schemes. + column widths), which applies to every theme (all are qdarkstyle-based). """ try: return bool(_app(app).styleSheet()) diff --git a/dev/studio_preview.py b/dev/studio_preview.py new file mode 100644 index 00000000..39ee676a --- /dev/null +++ b/dev/studio_preview.py @@ -0,0 +1,73 @@ +"""Offscreen (or on-screen) preview of the Studio layout. + +Builds the real :class:`StudioShell` populated with the mockup's content and +renders it in both shells — Studio (dark) and Atlas (light) — for sign-off. + +Run it against the worktree (it imports the package, so set PYTHONPATH): + + PYTHONPATH= QT_QPA_PLATFORM=offscreen \\ + python dev/studio_preview.py --out # write studio.png + atlas.png + + PYTHONPATH= python dev/studio_preview.py --show studio # interactive + +The Studio widgets are a pure view layer, so this needs only PySide6 + +qdarkstyle from the runtime — none of the heavy field/series deps. +""" +import argparse +import os +import sys + +from PySide6.QtWidgets import QApplication + +from PyReconstruct.modules.gui.studio.shell import StudioShell + +WIDTH, HEIGHT = 1200, 604 +THEMES = ("studio", "atlas") + + +def render(out_dir, width=WIDTH, height=HEIGHT): + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + app = QApplication.instance() or QApplication(sys.argv) + os.makedirs(out_dir, exist_ok=True) + paths = {} + shell = StudioShell.demo() + shell.resize(width, height) + shell.show() + for name in THEMES: + shell.apply_theme(name, app_wide=True) + app.processEvents() + app.processEvents() + pm = shell.grab() + path = os.path.join(out_dir, f"{name}.png") + pm.save(path, "PNG") + paths[name] = path + print(f"{name}: {path} ({pm.width()}x{pm.height()})") + return paths + + +def show(theme_name): + app = QApplication.instance() or QApplication(sys.argv) + shell = StudioShell.demo() + shell.resize(WIDTH, HEIGHT) + shell.apply_theme(theme_name, app_wide=True) + shell.setWindowTitle(f"PyReconstruct — Studio layout preview ({theme_name})") + shell.show() + sys.exit(app.exec()) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", default=None, help="directory to write studio.png + atlas.png") + ap.add_argument("--show", choices=THEMES, help="show a theme interactively") + ap.add_argument("--width", type=int, default=WIDTH) + ap.add_argument("--height", type=int, default=HEIGHT) + args = ap.parse_args() + if args.show: + show(args.show) + else: + out = args.out or os.path.join(os.path.dirname(__file__), "..", "previews", "studio") + render(os.path.abspath(out), args.width, args.height) + + +if __name__ == "__main__": + main() diff --git a/tests/test_icons.py b/tests/test_icons.py index 2dea63bf..d91bddc0 100644 --- a/tests/test_icons.py +++ b/tests/test_icons.py @@ -16,7 +16,8 @@ # tools whose palette buttons load a PNG today and should get a modern SVG -# (Flag/Host stay as glyphs and are intentionally absent) +# (Flag/Host historically fell back to glyphs; they now have Studio-set icons +# too — see STUDIO_ICONS below) EXPECTED_TOOLS = { "pointer", "panzoom", "knife", "scissors", "closedtrace", "opentrace", "stamp", "grid", "ztool", @@ -28,6 +29,16 @@ def test_expected_tools_present(): "every standard mode tool must have a modern SVG" +# Studio-layout chrome icons (activity rail panels + the tool rail's Flag/Host +# tiles); these replace the mockup's placeholder unicode glyphs. +STUDIO_ICONS = {"objects", "traces", "sections", "scene3d", "settings", "flag", "host"} + + +def test_studio_chrome_icons_present(): + assert STUDIO_ICONS <= set(icon_svgs.TOOL_SVGS), \ + "the Studio activity/tool rails must have stroked vector icons (no unicode glyphs)" + + def test_svgs_are_wellformed_bytes(): for name, svg in icon_svgs.TOOL_SVGS.items(): assert isinstance(svg, bytes), f"{name} svg must be bytes" @@ -56,7 +67,7 @@ def qapp(): def test_render_tints_opaque_pixels(qapp): from PyReconstruct.modules.gui.utils import icons from PySide6.QtGui import QColor - color = "#4c8dff" + color = "#e84d8a" # any vivid non-grayscale tint exercises the exactness check target = QColor(color) # use a solid-filled icon (pointer) so there are plenty of fully-opaque # pixels to assert exactness on; thin line-art icons are mostly anti-aliased @@ -77,12 +88,14 @@ def test_render_tints_opaque_pixels(qapp): def test_tool_icon_known_and_unknown(qapp): from PyReconstruct.modules.gui.utils import icons - assert icons.has_icon("knife") and not icons.has_icon("flag") + # known tools (incl. the now-present Flag/Host) resolve; an unknown name does not + assert icons.has_icon("knife") and icons.has_icon("flag") and icons.has_icon("host") + assert not icons.has_icon("no_such_tool") icon = icons.tool_icon("knife", 32, "#ffffff") assert not icon.isNull() assert not icon.pixmap(32, 32).isNull() # unknown tool -> empty icon (caller falls back to PNG/glyph) - assert icons.tool_icon("flag", 32).isNull() + assert icons.tool_icon("no_such_tool", 32).isNull() def test_tool_icon_defaults_to_theme_color(qapp): diff --git a/tests/test_studio_layout.py b/tests/test_studio_layout.py new file mode 100644 index 00000000..b2a10242 --- /dev/null +++ b/tests/test_studio_layout.py @@ -0,0 +1,291 @@ +"""Tests for the Studio layout widgets (gui.studio). + +These pin the foundation's structure and the spec's non-negotiables that can be +checked headlessly: the chrome stylesheet builds for both shells and never +carries azure; the activity rail and tool rail switch active state and signal; +the Objects panel renders a virtualizing model/view, filters, and reports +selection; the canvas hosts the glassy floats and steps sections; the palette +strip and status bar behave; and the whole shell assembles, toggles Studio ↔ +Atlas, and grabs to a non-null pixmap with no azure in its applied chrome. + +Qt-backed; skipped if PySide6/qdarkstyle are unavailable. +""" +import os +import pytest + +pytest.importorskip("PySide6") +pytest.importorskip("PySide6.QtSvg") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication, QWidget # noqa: E402 + +from PyReconstruct.modules.gui.studio import qss # noqa: E402 +from PyReconstruct.modules.gui.studio.rail import ActivityRail, RAIL_ITEMS # noqa: E402 +from PyReconstruct.modules.gui.studio.tool_rail import ToolRail, TOOL_ITEMS # noqa: E402 +from PyReconstruct.modules.gui.studio.objects_panel import ObjectsPanel # noqa: E402 +from PyReconstruct.modules.gui.studio.canvas import CanvasArea # noqa: E402 +from PyReconstruct.modules.gui.studio.palette_strip import PaletteStrip # noqa: E402 +from PyReconstruct.modules.gui.studio.status_bar import StudioStatusBar # noqa: E402 +from PyReconstruct.modules.gui.studio.shell import StudioShell # noqa: E402 + +AZURE_HUES = ("#4c8dff", "#3d8bd4") + + +@pytest.fixture(scope="module") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +def _collect(signal): + """Connect a signal to a list and return it for later assertions.""" + seen = [] + signal.connect(lambda *a: seen.append(a[0] if len(a) == 1 else a)) + return seen + + +# --- chrome stylesheet ------------------------------------------------------- +@pytest.mark.parametrize("theme_name", ["studio", "atlas"]) +def test_chrome_qss_builds_and_has_no_azure(qapp, theme_name): + css = qss.chrome_qss(theme_name) + assert css and "#studioActivityRail" in css and "#studioToolRail" in css + assert "#studioObjectsPanel" in css and "#studioStatusBar" in css + low = css.lower() + for azure in AZURE_HUES: + assert azure not in low, f"azure {azure} in {theme_name} chrome" + + +def test_chrome_qss_differs_between_shells(qapp): + # same selectors, different neutral shell (Studio dark vs Atlas light) + assert qss.chrome_qss("studio") != qss.chrome_qss("atlas") + assert "#0c0f14" in qss.chrome_qss("studio") # dark ground + assert "#f4f6fa" in qss.chrome_qss("atlas") # light ground + # the teal accent is in both + assert "#37c0a6" in qss.chrome_qss("studio") and "#37c0a6" in qss.chrome_qss("atlas") + + +# --- activity rail ----------------------------------------------------------- +def test_activity_rail_switches_and_signals(qapp): + rail = ActivityRail() + keys = [it[0] for it in RAIL_ITEMS if it] + assert {"objects", "traces", "sections", "flags", "scene3d", "settings"} <= set(keys) + assert rail.active_key() == "objects" # default + fired = _collect(rail.activated) + rail._on_click("traces") + assert rail.active_key() == "traces" + assert rail._buttons["traces"].is_active() + assert not rail._buttons["objects"].is_active() + assert fired == ["traces"] + rail.retint("#6a7686", "#37c0a6") # no raise + + +# --- tool rail --------------------------------------------------------------- +def test_tool_rail_flush_switches_and_signals(qapp): + tr = ToolRail() + keys = [it[0] for it in TOOL_ITEMS if it] + assert {"pointer", "panzoom", "closedtrace", "opentrace", "knife", + "stamp", "grid", "flag", "host"} <= set(keys) + assert tr.active_key() == "pointer" + assert tr.width() == 60 + fired = _collect(tr.tool_selected) + tr._on_click("knife") + assert tr.active_key() == "knife" and fired == ["knife"] + assert tr._buttons["knife"].is_active() + + +# --- objects panel ----------------------------------------------------------- +def _objs(n): + return [{"name": f"obj_{i}", "type": "dendrite" if i % 2 else "", + "color": "#37c0a6", "status": ("curated", "review", "flagged")[i % 3]} + for i in range(n)] + + +def test_objects_panel_populates_filters_and_selects(qapp): + panel = ObjectsPanel() + panel.set_objects(_objs(50)) + assert panel._proxy.rowCount() == 50 + assert panel._count.text() == "50" + # selection fires object_activated; first row auto-selected on set + fired = _collect(panel.object_activated) + panel.view.setCurrentIndex(panel._proxy.index(3, 0)) + assert fired and fired[-1] == "obj_3" + # live filter narrows the proxy and updates the count + panel.filter.setText("obj_1") # obj_1, obj_10..obj_19 + assert 0 < panel._proxy.rowCount() < 50 + assert panel._count.text() == str(panel._proxy.rowCount()) + panel.filter.setText("") + assert panel._proxy.rowCount() == 50 + + +def test_objects_panel_retitle_and_theme(qapp): + panel = ObjectsPanel() + panel.set_title("traces") + assert panel._title.text() == "TRACES" + panel.apply_theme("atlas") # re-tints delegate/legend without error + panel.apply_theme("studio") + + +def test_objects_panel_virtualizes_large_list(qapp): + # a large model must not create per-row widgets (model/view + delegate) + panel = ObjectsPanel() + panel.set_objects(_objs(4000)) + assert panel._proxy.rowCount() == 4000 + assert panel._count.text() == "4,000" # mono count, comma-grouped + + +# --- canvas + floats --------------------------------------------------------- +def test_canvas_floats_and_section_step(qapp): + canvas = CanvasArea() + # all four glassy floats exist + for f in (canvas.badge, canvas.scalebar, canvas.secnav, canvas.bc): + assert f.parent() is canvas + stepped = _collect(canvas.section_changed) + canvas.secnav.stepped.emit(1) + assert stepped == [1] + canvas.set_section(149, 287) + canvas.set_active_object("d001", "curated") + canvas.set_brightness(70) + canvas.set_contrast(30) + assert canvas.bc.bright.value() == 70 and canvas.bc.contr.value() == 30 + canvas.apply_theme("atlas") + + +def test_canvas_widget_seam(qapp): + canvas = CanvasArea() + real = QWidget() + canvas.set_canvas_widget(real) + assert real.parent() is canvas + # floats still present and on top + assert canvas.badge.parent() is canvas + + +def test_canvas_bc_signals(qapp): + canvas = CanvasArea() + b = _collect(canvas.brightness_changed) + canvas.bc.bright.setValue(80) + assert b == [80] + + +# --- palette strip ----------------------------------------------------------- +def test_palette_strip_select_and_add(qapp): + strip = PaletteStrip() + strip.set_colors(["#6ce0c4", "#f0a6d8", "#9bd35a", "#37c0a6"]) + strip.set_active(0) + assert len(strip._swatches) == 4 + assert strip._swatches[0]._active and not strip._swatches[1]._active + picked = _collect(strip.color_selected) + strip._on_click(2) + assert picked == [2] and strip._swatches[2]._active + added = _collect(strip.add_requested) + strip._add.click() + assert added # add_requested fired + strip.set_readout("circle · r=0.045") + assert "0.045" in strip._readout.text() + + +# --- status bar -------------------------------------------------------------- +def test_status_bar_values_are_teal(qapp): + sb = StudioStatusBar() + sb.set_section(148) + sb.set_zoom("240%") + # active values carry the teal accent; labels do not + assert "#37c0a6" in sb._section.text() and "148" in sb._section.text() + assert "section" in sb._section.text() + assert "240%" in sb._zoom.text() + + +# --- the whole shell --------------------------------------------------------- +def test_shell_demo_assembles(qapp): + shell = StudioShell.demo() + # every region is present + for region in (shell.title_strip, shell.rail, shell.objects_panel, + shell.canvas, shell.tool_rail, shell.palette_strip, + shell.status_bar): + assert region is not None and region.parent() is not None + # the demo object list reads like the mockup count and virtualizes + assert shell.objects_panel._proxy.rowCount() == 3809 + assert shell.objects_panel._count.text() == "3,809" + assert shell.rail.active_key() == "objects" + assert shell.tool_rail.active_key() == "pointer" + + +def test_shell_rail_repoints_panel(qapp): + shell = StudioShell.demo() + shell._on_rail("traces") + assert shell.objects_panel._title.text() == "TRACES" + assert 0 < shell.objects_panel._proxy.rowCount() < 3809 + shell._on_rail("objects") + assert shell.objects_panel._proxy.rowCount() == 3809 + + +def test_shell_seam_items_light_but_dont_repoint(qapp): + # 3D / Settings are honest seams: clicking lights the rail + emits, but the + # left panel must NOT repoint (no fake feature behind them). + shell = StudioShell.demo() + title_before = shell.objects_panel._title.text() + rows_before = shell.objects_panel._proxy.rowCount() + fired = _collect(shell.rail.activated) + for seam in ("scene3d", "settings"): + shell.rail._on_click(seam) + assert shell.rail.active_key() == seam + assert seam in fired + assert shell.objects_panel._title.text() == title_before + assert shell.objects_panel._proxy.rowCount() == rows_before + + +def test_shell_object_selection_updates_badge(qapp): + shell = StudioShell.demo() + shell._on_object("psd_018") # a flagged object + assert "psd_018" in shell.canvas.badge._text.text() + assert shell.canvas.badge._status == "flagged" + + +def test_shell_section_step(qapp): + shell = StudioShell.demo() + shell._on_section_step(1) + assert shell._section_idx == 149 + assert "149" in shell.status_bar._section.text() + # clamps at the bottom + shell._section_idx = 1 + shell._on_section_step(-1) + assert shell._section_idx == 1 + + +@pytest.mark.parametrize("theme_name", ["studio", "atlas"]) +def test_shell_theme_toggle_no_azure(qapp, theme_name): + shell = StudioShell.demo() + shell.apply_theme(theme_name, app_wide=False) + low = shell.styleSheet().lower() + assert low, "chrome stylesheet must be applied to the shell" + for azure in AZURE_HUES: + assert azure not in low, f"azure {azure} in shell chrome for {theme_name}" + + +def test_title_strip_brand_uses_bare_logo_and_single_wordmark(qapp): + from PyReconstruct.modules.gui.studio._common import logo_path + from PyReconstruct.modules.gui.studio.title_strip import StudioTitleStrip + ts = StudioTitleStrip() + ts.apply_theme("studio") + ts.apply_theme("atlas") # neither errors; the mark is theme-free + assert logo_path() and logo_path().endswith("logo.png") + # the wordmark is one label (no stray "Py | Reconstruct" gap), teal accent on + # "Reconstruct" + text = ts._brand_label.text() + assert text.startswith("Py") and "Reconstruct" in text + assert "#37c0a6" in text + + +def test_shell_sets_window_icon(qapp): + from PyReconstruct.modules.gui.studio._common import logo_path + shell = StudioShell.demo() + if logo_path(): # asset present -> window icon is set (theme-independent) + assert not shell.windowIcon().isNull() + + +def test_shell_renders_offscreen(qapp): + shell = StudioShell.demo() + shell.resize(1180, 560) + shell.show() + qapp.processEvents() + pm = shell.grab() + assert not pm.isNull() and pm.width() >= 1180 diff --git a/tests/test_theme.py b/tests/test_theme.py index 76e3afa6..e2f7bf70 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,25 +1,38 @@ """Tests for the application theme engine (gui.utils.theme). The theme defaults to the OS color scheme and can be overridden to -System / Light / Dark, persisted app-wide via QSettings. These pin: +System / Light / Dark / Studio / Atlas, persisted app-wide via QSettings. +These pin: - * the pure mode/scheme resolution (no Qt) over its full matrix, including the - legacy "default"/"qdark" migration and the Unknown->dark fallback that keeps - chrome and the app icon in agreement; - * the Qt-backed pieces (skipped if PySide6/qdarkstyle are unavailable): that - light and dark build distinct non-empty stylesheets carrying the accent - token, that apply_theme installs one app-wide, and that the persisted choice - round-trips through QSettings. + * the pure mode/theme/scheme resolution (no Qt) over its full matrix, + including the legacy "default"/"qdark" migration, the Unknown->dark + fallback that keeps chrome and the app icon in agreement, and the named + Studio/Atlas themes (their family, accent, and on-accent ink); + * the qdarkstyle-base stylesheets (skipped if PySide6/qdarkstyle are + unavailable): that the four concrete themes build distinct non-empty + stylesheets, that the named themes carry the teal accent (and never the + azure one, nor any leftover qdarkstyle hue), that light/dark are left + untouched, that apply_theme installs one app-wide, and that the persisted + choice round-trips through QSettings; + * the Studio-layout chrome-token table (studio_tokens): its family selection, + the single teal accent, the always-dark canvas in both shells, and the hard + rule that no azure leaks into the Studio chrome. The Qt fixture redirects QSettings to a temp dir so a test run never touches the user's real theme preference. """ import os +import re import pytest from PyReconstruct.modules.gui.utils import theme as t +# the azure hues that must never appear in the Studio/Atlas chrome (the recurring +# first-attempt failure was azure #4c8dff leaking in from Refined Familiar) +AZURE_HUES = ("#4c8dff", "#3d8bd4") + + # --- pure logic (no Qt) ------------------------------------------------------ @pytest.mark.parametrize("value,expected", [ ("system", "system"), @@ -55,6 +68,124 @@ def test_unknown_fallback_matches_icon_logic(): assert t.UNKNOWN_FALLBACK == "dark" +# --- named themes ("Studio" / "Atlas"): pure logic (no Qt) ------------------- +@pytest.mark.parametrize("value", ["studio", "atlas"]) +def test_normalize_mode_named_themes(value): + # the named themes are first-class selectable modes (round-trip unchanged) + assert t.normalize_mode(value) == value + + +@pytest.mark.parametrize("mode,sys_light,expected", [ + ("studio", None, "studio"), # an explicit named theme ignores the OS + ("studio", True, "studio"), + ("atlas", None, "atlas"), + ("atlas", False, "atlas"), + ("light", True, "light"), + ("dark", False, "dark"), + ("system", True, "light"), + ("system", False, "dark"), + ("system", None, "dark"), # Unknown OS scheme -> UNKNOWN_FALLBACK +]) +def test_resolve_theme(mode, sys_light, expected): + assert t.resolve_theme(mode, sys_light) == expected + + +@pytest.mark.parametrize("mode,family", [ + ("studio", "dark"), # Studio is dark-family (drives the dark app-icon glyph) + ("atlas", "light"), # Atlas is light-family + ("light", "light"), + ("dark", "dark"), +]) +def test_named_theme_family(mode, family): + # resolve_scheme stays a light/dark answer even for the named themes, so the + # app-icon glyph and tool-icon tint keep working unchanged. + assert t.resolve_scheme(mode, None) == family + + +def test_modes_themes_schemes_consistent(): + assert set(t.THEMES) <= set(t.MODES) + assert set(t.MODES) - set(t.THEMES) == {"system"} # system is the only non-theme mode + assert set(t.SCHEMES) == {"light", "dark"} + assert {"studio", "atlas"} <= set(t.THEMES) + + +def test_accent_and_icon_per_theme(): + # named themes share the teal accent; light/dark keep the azure one + assert t.accent_color("studio") == t.accent_color("atlas") == t.STUDIO_ACCENT + assert t.accent_color("light") == t.accent_color("dark") == t.ACCENT + assert t.STUDIO_ACCENT != t.ACCENT + # tool-icon tints differ per theme and are never the accent itself + for theme_name in t.THEMES: + assert t.icon_color(theme_name) not in (t.ACCENT, t.STUDIO_ACCENT) + assert t.icon_color("studio") != t.icon_color("atlas") + + +def test_accent_text_per_theme(): + # white ink reads on the azure light/dark accent; the brighter Studio/Atlas + # teal needs a dark ink instead (white on #37c0a6 is ~2.3:1, a WCAG fail). + assert t.accent_text("light") == t.accent_text("dark") == t.ACCENT_TEXT == "#ffffff" + for named in ("studio", "atlas"): + assert t.accent_text(named) != t.ACCENT_TEXT, "teal needs a dark on-accent ink" + assert t.accent_text(named) == t.STUDIO_GROUND + + +def test_remap_leaves_longer_hex_intact(): + # The remap targets only whole 6-digit tokens: a longer (8-digit alpha) hex + # must be left untouched, not partially rewritten. + class _P: + COLOR_X = "#19232d" + src = "a{color:#19232d}b{color:#19232dff}" + out = t._remap_palette(src, _P, {"COLOR_X": "#0c0f14"}) + assert "a{color:#0c0f14}" in out # standalone 6-digit token was remapped + assert "#19232dff" in out # 8-digit token left intact (not corrupted) + + +# --- Studio-layout chrome tokens (studio_tokens): pure logic (no Qt) --------- +def test_studio_tokens_family_selection(): + # Studio and bare "dark" share the dark shell; Atlas and "light" the light. + assert t.studio_tokens("studio") == t.studio_tokens("dark") + assert t.studio_tokens("atlas") == t.studio_tokens("light") + assert t.studio_tokens("studio")["family"] == "dark" + assert t.studio_tokens("atlas")["family"] == "light" + # the shells differ on the neutral ground but never on the accent + assert t.studio_tokens("studio")["bg"] == "#0c0f14" + assert t.studio_tokens("atlas")["bg"] == "#f4f6fa" + + +def test_studio_tokens_one_teal_accent_dark_ink(): + for shell in ("studio", "atlas"): + tok = t.studio_tokens(shell) + assert tok["accent"] == t.STUDIO_ACCENT == "#37c0a6" + assert tok["on_accent"] == "#0c0f14", "dark ink on solid teal fills" + # curation status hues are the fixed data palette, identical in both + assert (tok["ok"], tok["warn"], tok["bad"]) == ("#3ecf8e", "#f4bd4f", "#f07a72") + + +def test_studio_tokens_canvas_dark_in_both_shells(): + # The EM canvas — and the glassy controls floating on it — stay dark in both + # Studio and Atlas, so every canvas/float token is shell-independent. + dark, light = t.studio_tokens("studio"), t.studio_tokens("atlas") + for key in ("canvas_bg", "float_bg", "float_line", "float_ink", "scale_ink", + "raised_dark", "accent", "on_accent", "ok", "warn", "bad"): + assert dark[key] == light[key], f"{key} must match across shells" + assert dark["canvas_bg"] == "#0c0f14" + assert dark["float_bg"].startswith("rgba("), "alpha float bg needs Qt rgba(), not #rrggbbaa" + + +def test_studio_tokens_no_azure(): + # The hard rule: no azure anywhere in the Studio/Atlas chrome tokens. + for shell in ("studio", "atlas"): + blob = " ".join(str(v) for v in t.studio_tokens(shell).values()).lower() + for azure in AZURE_HUES: + assert azure not in blob, f"azure {azure} leaked into {shell} chrome tokens" + + +def test_studio_tokens_returns_fresh_copy(): + a = t.studio_tokens("studio") + a["bg"] = "#ffffff" + assert t.studio_tokens("studio")["bg"] == "#0c0f14", "caller mutation must not stick" + + # --- Qt-backed (skipped without PySide6 / qdarkstyle) ------------------------ @pytest.fixture(scope="module") def qapp(tmp_path_factory): @@ -81,9 +212,9 @@ def test_build_stylesheet_light_dark_differ(qapp): def test_apply_theme_installs_stylesheet(qapp): - for mode in ("light", "dark", "system"): - scheme = t.apply_theme(qapp, mode) - assert scheme in t.SCHEMES + for mode in ("light", "dark", "studio", "atlas", "system"): + theme_applied = t.apply_theme(qapp, mode) + assert theme_applied in t.THEMES assert qapp.styleSheet(), f"mode {mode!r} left an empty app stylesheet" assert t.qss_active(qapp) is True @@ -98,7 +229,7 @@ def test_toggle_changes_active_stylesheet(qapp): def test_persist_roundtrip_and_legacy_migration(qapp): from PySide6.QtCore import QSettings - for mode in ("system", "light", "dark"): + for mode in ("system", "light", "dark", "studio", "atlas"): t.write_mode(mode) assert t.read_mode() == mode # legacy stored values are migrated on read-back @@ -106,3 +237,89 @@ def test_persist_roundtrip_and_legacy_migration(qapp): assert t.read_mode() == "dark" QSettings(t.QSETTINGS_ORG, t.QSETTINGS_APP).setValue(t.THEME_KEY, "default") assert t.read_mode() == "system" + + +# --- named themes: Qt-backed (skipped without PySide6 / qdarkstyle) ---------- +def _hexes(qss): + return {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", qss)} + + +def test_studio_atlas_stylesheets_distinct_and_teal(qapp): + light = t.build_stylesheet("light") + dark = t.build_stylesheet("dark") + studio = t.build_stylesheet("studio") + atlas = t.build_stylesheet("atlas") + for ss in (studio, atlas): + assert ss, "named-theme stylesheet must be non-empty" + assert t.STUDIO_ACCENT in ss, "named themes paint the teal accent" + assert len({light, dark, studio, atlas}) == 4, "all four themes are distinct" + # named themes carry teal (not azure); light/dark carry azure (not teal) + assert t.ACCENT not in studio and t.ACCENT not in atlas + assert t.STUDIO_ACCENT not in light and t.STUDIO_ACCENT not in dark + + +def test_studio_atlas_stylesheets_have_no_azure(qapp): + # zero azure anywhere in the named-theme stylesheets + for theme_name in ("studio", "atlas"): + ss = t.build_stylesheet(theme_name).lower() + for azure in AZURE_HUES: + assert azure not in ss, f"azure {azure} leaked into the {theme_name} stylesheet" + + +def test_light_dark_not_remapped(qapp): + # The named-theme remap must leave the historical light/dark look untouched: + # *every* hue qdarkstyle emits survives (the base is not remapped at all), + # and only the azure add-on is appended. + import qdarkstyle + from qdarkstyle import DarkPalette, LightPalette + for palette, theme_name in ((DarkPalette, "dark"), (LightPalette, "light")): + base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=palette) + built = t.build_stylesheet(theme_name) + assert _hexes(base) <= _hexes(built), "a qdarkstyle hue was altered" + assert t.ACCENT in built # azure accent add-on present + assert t.STUDIO_ACCENT not in built # no teal leaked into light/dark + + +def test_studio_remaps_dark_ground(qapp): + from qdarkstyle import DarkPalette + studio = t.build_stylesheet("studio") + # qdarkstyle's dark ground is gone; Studio's charcoal ground is in + assert DarkPalette.COLOR_BACKGROUND_1.lower() not in studio.lower() + assert t.STUDIO_GROUND in studio + + +def test_atlas_remaps_light_ground(qapp): + from qdarkstyle import LightPalette + atlas = t.build_stylesheet("atlas") + assert LightPalette.COLOR_BACKGROUND_1.lower() not in atlas.lower() + assert t.ATLAS_GROUND in atlas + + +def test_remap_is_complete_no_qdarkstyle_hue_survives(qapp): + # Every hue in a remapped stylesheet is a declared target (plus the ink drawn + # on the accent) — proof the single-pass remap covered the whole base QSS and + # no original qdarkstyle palette color leaked through. + studio = t.build_stylesheet("studio") + allowed = {v.lower() for v in t._STUDIO_MAP.values()} | {t.accent_text("studio").lower()} + assert _hexes(studio) <= allowed + atlas = t.build_stylesheet("atlas") + allowed = {v.lower() for v in t._ATLAS_MAP.values()} | {t.accent_text("atlas").lower()} + assert _hexes(atlas) <= allowed + + +def test_studio_remap_applies_to_every_used_slot(qapp): + # Self-adjusting fidelity check: for each Studio remap whose qdarkstyle source + # slot is actually used in the base QSS, the Studio target must appear. (Some + # qdarkstyle slots — e.g. BACKGROUND_2 — are defined but unused, so their + # targets legitimately don't render; this asserts the ones that should, do.) + import qdarkstyle + from qdarkstyle import DarkPalette + base = qdarkstyle.load_stylesheet(qt_api="pyside6", palette=DarkPalette).lower() + studio = t.build_stylesheet("studio").lower() + for cname, target in t._STUDIO_MAP.items(): + if getattr(DarkPalette, cname).lower() in base: + assert target.lower() in studio, f"{cname} -> {target} was not applied" + # the Studio identity anchors (ground, hairline, ink, teal) all map to used + # slots, so they are guaranteed present + for hexv in ("#0c0f14", "#2a323e", "#e8edf3", "#37c0a6"): + assert hexv in studio, f"Studio anchor {hexv} missing"