Skip to content
5 changes: 4 additions & 1 deletion PyReconstruct/modules/gui/dialog/all_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ def setOption(response):
("System", mode == "system"),
("Light", mode == "light"),
("Dark", mode == "dark"),
("Studio (dark)", mode == "studio"),
("Atlas (light)", mode == "atlas"),
)],
]

Expand All @@ -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
Expand Down
18 changes: 13 additions & 5 deletions PyReconstruct/modules/gui/main/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -3070,18 +3070,22 @@ 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 = [
["Theme:"],
[("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"
Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions PyReconstruct/modules/gui/studio/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
89 changes: 89 additions & 0 deletions PyReconstruct/modules/gui/studio/_common.py
Original file line number Diff line number Diff line change
@@ -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))
Loading