diff --git a/PyReconstruct/modules/gui/main/field_widget_7_view.py b/PyReconstruct/modules/gui/main/field_widget_7_view.py index 3576d76c..b7401ebc 100644 --- a/PyReconstruct/modules/gui/main/field_widget_7_view.py +++ b/PyReconstruct/modules/gui/main/field_widget_7_view.py @@ -196,6 +196,11 @@ def setLeftHanded(self, left_handed=None): self.mouse_mode == CLOSEDTRACE): cursor = self.pencil_l if self.series.getOption("left_handed") else self.pencil_r if cursor != self.cursor(): self.setCursor(cursor) + + # flip the floating tool palette to the matching field edge + palette = getattr(self.mainwindow, "mouse_palette", None) + if palette is not None: + palette.applyHandedness() def endPendingEvents(self): """End ongoing events that are connected to the mouse.""" diff --git a/PyReconstruct/modules/gui/palette/buttons.py b/PyReconstruct/modules/gui/palette/buttons.py index d34249c3..f3488a2b 100644 --- a/PyReconstruct/modules/gui/palette/buttons.py +++ b/PyReconstruct/modules/gui/palette/buttons.py @@ -92,6 +92,27 @@ class ModeButton(MoveableButton): def __init__(self, parent, manager): super().__init__(parent, manager, "mode") + self.mode_name = None # display name, set by MousePalette.createModeButton + self.keycap = None # the small shortcut-hint label (or None) + self._hovered = False + + def mouseMoveEvent(self, event): + """Docked in the tool-palette card — not individually draggable (the + card is edge-docked and flips with the left-handed option), so the + MoveableButton drag is intentionally suppressed. Clicks/right-clicks + still work via press/release.""" + pass + + def enterEvent(self, event): + """Brighten the resting icon on hover (prototype's ``.tool:hover``).""" + self._hovered = True + self.manager.styleModeButton(self) + super().enterEvent(event) + + def leaveEvent(self, event): + self._hovered = False + self.manager.styleModeButton(self) + super().leaveEvent(event) def highlightButton(self): """Mode buttons show their active state via the accent fill applied by diff --git a/PyReconstruct/modules/gui/palette/mouse_palette.py b/PyReconstruct/modules/gui/palette/mouse_palette.py index 63f9cb4b..fe883e0c 100644 --- a/PyReconstruct/modules/gui/palette/mouse_palette.py +++ b/PyReconstruct/modules/gui/palette/mouse_palette.py @@ -2,9 +2,13 @@ import re import math -from PySide6.QtWidgets import QWidget, QStyle, QSlider -from PySide6.QtGui import QIcon, QPixmap, QColor, QFont -from PySide6.QtCore import QSize, Qt, QSettings +from PySide6.QtWidgets import ( + QWidget, QStyle, QSlider, QFrame, QLabel, QVBoxLayout, QHBoxLayout, +) +from PySide6.QtGui import ( + QIcon, QPixmap, QColor, QFont, QPainter, QPen, QPainterPath, +) +from PySide6.QtCore import QSize, Qt, QRectF, QSettings # Palette-visibility preferences are global (a UI choice, not per-series) and # persist across launches. Map each in-memory flag to its QSettings key. @@ -93,6 +97,116 @@ def clear_palette_positions(settings : QSettings) -> None: from PyReconstruct.modules.gui.utils import theme +class PaletteCard(QFrame): + """The floating tool-palette card — the v1 prototype's ``.palette``. + + A rounded, faintly translucent panel that holds the mode buttons. Everything + is hand-painted in :meth:`paintEvent` (no ``QGraphicsEffect``, which Qt does + not apply under ``QWidget.render``/``grab`` and so would not show in the + offscreen preview): a layered soft drop shadow for the floating lift, the + rounded translucent body + hairline border (the translucency lets the field + show faintly through, standing in for the prototype's ``backdrop-filter: + blur`` — which Qt has no native equivalent for), and a soft accent halo + behind the active tool (the prototype's ``.tool.active`` glow). + + The widget is larger than the visible card by :data:`SHADOW_MARGIN` on every + side so the cast shadow has room to fall without being clipped to the body. + """ + + #: transparent padding around the visible body, holding the cast shadow + SHADOW_MARGIN = 16 + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.tool_buttons = [] # mode buttons, for drawing the active halo + self.dividers = [] # thin group separators + self.header = None # the "TOOLS" caption + self._radius = 14.0 # prototype --r-lg + self._bg = QColor("#161b22") + self._bg.setAlphaF(0.92) + self._border = QColor("#2a313c") + self._accent = QColor(theme.ACCENT) + self._shadow_strength = 140 # peak shadow alpha (tuned per scheme) + + def bodyRect(self) -> QRectF: + """The visible (rounded) card rect inside the shadow padding.""" + m = self.SHADOW_MARGIN + return QRectF(self.rect()).adjusted(m, m, -m, -m) + + def applyTheme(self, tokens: dict, accent_hex: str, + alpha: float = 0.92, shadow_alpha: int = 140): + """Recolor the card, header, and dividers for the active theme.""" + self._bg = QColor(tokens["panel"]) + self._bg.setAlphaF(alpha) + self._border = QColor(tokens["hair"]) + self._accent = QColor(accent_hex) + self._shadow_strength = shadow_alpha + if self.header is not None: + self.header.setStyleSheet( + "color: %s; background: transparent;" % tokens["txt_faint"] + ) + for d in self.dividers: + line = getattr(d, "line", d) # recolor the inset hairline + line.setStyleSheet( + "background-color: %s; border: 0px;" % tokens["hair"] + ) + self.update() + + @staticmethod + def _rounded(rect: QRectF, radius: float) -> QPainterPath: + path = QPainterPath() + path.addRoundedRect(rect, radius, radius) + return path + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + body = self.bodyRect() + + # soft drop shadow (prototype --shadow): layered, offset-down rounded + # rects painted largest/faintest first. + steps = 12 + for i in range(steps, 0, -1): + frac = i / steps + grow = frac * 7.0 + dy = frac * 9.0 + 2.0 + alpha = int(self._shadow_strength * (1.0 - frac) * 0.5) + 3 + col = QColor(0, 0, 0, max(0, min(255, alpha))) + sr = body.adjusted(-grow, -grow + dy, grow, grow + dy) + p.fillPath(self._rounded(sr, self._radius + grow), col) + + inner = body.adjusted(0.5, 0.5, -0.5, -0.5) + path = self._rounded(inner, self._radius) + + # translucent body + p.fillPath(path, self._bg) + + # active-tool halo, clipped to the body so it never bleeds past the + # rounded corners; the button paints its opaque accent fill on top, + # leaving the halo as a glow rim. + p.save() + p.setClipPath(path) + for b in self.tool_buttons: + if b.isChecked(): + base = QRectF(b.geometry()) + for grow, a in ((7.0, 42), (4.0, 70), (1.5, 105)): + c = QColor(self._accent) + c.setAlpha(a) + gl = base.adjusted(-grow, -grow + 3.0, grow, grow + 3.0) + p.fillPath(self._rounded(gl, 12.0), c) + break + p.restore() + + # hairline border on top of body + halo + pen = QPen(self._border) + pen.setWidthF(1.0) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + p.drawPath(path) + p.end() + + class MousePalette(): def __init__(self, mainwindow : QWidget): @@ -107,7 +221,7 @@ def __init__(self, mainwindow : QWidget): self.series = self.mainwindow.series self.series : Series - self.mblen = 40 # mode button size + self.mblen = 38 # mode (tool) button size — prototype .tool is 38px self.pblen = 40 # palette button size self.ibw = 90 # inc button width self.ibh = 35 # inc button height @@ -115,10 +229,16 @@ def __init__(self, mainwindow : QWidget): self.is_dragging = False + # active theme tokens for the floating tool-palette chrome + self._tokens = theme.tokens() + # Moveable palette-group positions (fractions 0..1 of the field bounds). # Set every default first, then override with any persisted positions, so # each group renders in its saved spot. Saved on drag end (MoveableButton) - # and cleared by resetPos(). + # and cleared by resetPos(). mode_x/mode_y also record which field edge the + # tool card docks to (right by default, left when left-handed); + # placePaletteCard recomputes this and the corner-text painter reads mode_x + # to stay clear of the palette. self.mode_x, self.mode_y = 0.99, 0.01 self.trace_x, self.trace_y = 0.51, 0.99 self.inc_x, self.inc_y = 0.99, 0.99 @@ -128,17 +248,7 @@ def __init__(self, mainwindow : QWidget): # create mode buttons self.mode_buttons = {} - self.createModeButton("Pointer", 0) - self.createModeButton("Pan/Zoom", 1) - self.createModeButton("Knife", 2) - self.createModeButton("Scissors", 3) - self.createModeButton("Closed Trace", 4) - self.createModeButton("Open Trace", 5) - self.createModeButton("Stamp", 6) - self.createModeButton("Grid", 7) - self.createModeButton("Flag", 8) - self.createModeButton("Host", 9) - self.createModeButton("Ztool", 10) + self.createPaletteCard() # create palette buttons traces = self.series.palette_traces[self.series.palette_index[0]] @@ -179,17 +289,6 @@ def __init__(self, mainwindow : QWidget): # apply the restored visibility now that every palette widget exists self.applyVisibilityState() - def placeModeButton(self, button, pos : int): - """Place the mode button in the main window. - - Params: - button (ModeButton): the button to place - pos (int): the position of the button - """ - x, y = self.getButtonCoords("mode") - y += (10 + self.mblen) * pos - button.setGeometry(x, y, self.mblen, self.mblen) - @staticmethod def _stripped(name : str) -> str: """Tool name -> icon key/filename (lower-cased, spaces/slashes removed).""" @@ -199,81 +298,153 @@ def _stripped(name : str) -> str: return stripped.lower() def _mode_icon_px(self) -> int: - """Render size for a mode icon — padded within the button like the - prototype (icon ~60% of the button).""" - return round(self.mblen * 0.6) - - def refreshModeIcons(self): - """Re-tint the mode-button icons to the current theme. Call when the - theme changes (or the active tool changes) so the monochrome line icons - follow light/dark and the active tool pops like the prototype: accent - background + white icon, resting tools at the theme icon color.""" - resting = theme.icon_color() - icon_px = self._mode_icon_px() - for name, (b, _mode, _pos) in self.mode_buttons.items(): - stripped = self._stripped(name) - self._applyModeButtonStyle(b) - if icon_utils.has_icon(stripped): - color = theme.ACCENT_TEXT if b.isChecked() else resting - b.setIcon(icon_utils.tool_icon(stripped, icon_px, color)) - b.setIconSize(QSize(icon_px, icon_px)) + """Render size for a mode icon — 20px line icons inside the 38px tool + button, matching the prototype's ``.tool svg{width:20px}``.""" + return 20 + + # tool display name -> the series option holding its keyboard shortcut, so + # the keycap hint + tooltip show the user's *current* (override-aware) key. + # Scissors and Ztool have no shortcut option (and so no keycap). + _SHORTCUT_OPTS = { + "Pointer": "usepointer_act", + "Pan/Zoom": "usepanzoom_act", + "Knife": "useknife_act", + "Closed Trace": "usectrace_act", + "Open Trace": "useotrace_act", + "Stamp": "usestamp_act", + "Grid": "usegrid_act", + "Flag": "useflag_act", + "Host": "usehost_act", + } - @staticmethod - def _applyModeButtonStyle(button): - """Give the active (checked) mode button the prototype's accent fill so - the selected tool reads as selected; resting buttons keep the chrome's - own styling. Re-applied on theme/active changes so it follows light/dark. + def _modeShortcut(self, name : str) -> str: + """The current keyboard shortcut for a mode (or "" if it has none).""" + opt = self._SHORTCUT_OPTS.get(name) + if not opt: + return "" + try: + return self.series.getOption(opt) or "" + except Exception: + return "" + + def createPaletteCard(self): + """Build the floating tool-palette card and its mode buttons. + + Buttons keep their original top-to-bottom order (which also encodes the + mouse-mode index); thin dividers separate them into the prototype's tool + families: navigate · cut · trace · annotate. """ - if button.isChecked(): - button.setStyleSheet( - "QPushButton { background-color: %s; border: 1px solid %s; " - "border-radius: 6px; }" % (theme.ACCENT, theme.ACCENT) - ) - else: - button.setStyleSheet("") + self.tool_card = PaletteCard(self.mainwindow) + sm = PaletteCard.SHADOW_MARGIN + # pin to the button width + 7px padding (prototype --pal-w:52px) plus the + # transparent shadow margin, so the header caption can never widen the + # visible card past the prototype. + self.tool_card.setFixedWidth(self.mblen + 14 + 2 * sm) + layout = QVBoxLayout(self.tool_card) + layout.setContentsMargins(7 + sm, 6 + sm, 7 + sm, 7 + sm) + layout.setSpacing(3) + + # "TOOLS" caption (prototype .ph) + header = QLabel("TOOLS", self.tool_card) + hf = QFont() + hf.setPixelSize(9) + hf.setBold(True) + hf.setLetterSpacing(QFont.AbsoluteSpacing, 0.7) + header.setFont(hf) + header.setAlignment(Qt.AlignHCenter) + header.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self.tool_card.header = header + layout.addWidget(header) + + # groups preserve the legacy order; (name, mouse_mode) — mouse_mode is + # the int passed to changeMouseMode and must stay 0..10 as before. + groups = [ + [("Pointer", 0), ("Pan/Zoom", 1)], # navigate + [("Knife", 2), ("Scissors", 3)], # cut + [("Closed Trace", 4), ("Open Trace", 5), ("Stamp", 6)], # trace + [("Grid", 7), ("Flag", 8), ("Host", 9), ("Ztool", 10)], # annotate + ] + for gi, group in enumerate(groups): + if gi > 0: + div = self._makeDivider() + layout.addWidget(div) + self.tool_card.dividers.append(div) + for name, mouse_mode in group: + b = self.createModeButton(name, mouse_mode) + layout.addWidget(b, 0, Qt.AlignHCenter) + self.tool_card.tool_buttons.append(b) + + # initial styling + placement + self.refreshModeIcons() + self.tool_card.show() + self.placePaletteCard() + + def _makeDivider(self) -> QWidget: + """A thin, inset group separator (prototype .palette .div: 1px hairline + inset 6px from each edge). The hairline lives inside a transparent + wrapper whose layout supplies the inset (a bare QWidget's contentsMargins + do not inset its background fill).""" + wrap = QWidget(self.tool_card) + wrap.setFixedHeight(1) + wrap.setAttribute(Qt.WA_TransparentForMouseEvents, True) + h = QHBoxLayout(wrap) + h.setContentsMargins(6, 0, 6, 0) + h.setSpacing(0) + line = QWidget(wrap) + line.setFixedHeight(1) + line.setStyleSheet( + "background-color: %s; border: 0px;" % self._tokens["hair"] + ) + h.addWidget(line) + wrap.line = line # the colored hairline (recolored on theme change) + return wrap def createModeButton(self, name : str, pos : int): - """Creates a new mouse mode button. + """Create one mouse-mode button for the tool-palette card. Params: - name (str): the name of the button (and icon key) - pos (int): the position of the button + name (str): the mode display name (and icon key) + pos (int): the mouse-mode index passed to changeMouseMode """ - b = ModeButton(self.mainwindow, self) + b = ModeButton(self.tool_card, self) + b.mode_name = name + b.setFixedSize(self.mblen, self.mblen) mouse_mode = pos - # filter name to get the stripped icon key / filename stripped_name = self._stripped(name) - self.placeModeButton(b, pos) - - # format the button — prefer the modern theme-tinted SVG icon; fall back - # to the legacy PNG for tools without one (Flag/Host then set a glyph). + # prefer the modern theme-tinted SVG icon; Flag/Host fall back to a glyph if icon_utils.has_icon(stripped_name): icon_px = self._mode_icon_px() - b.setIcon(icon_utils.tool_icon(stripped_name, icon_px)) + b.setIcon(icon_utils.tool_icon( + stripped_name, icon_px, self._tokens["txt_dim"])) b.setIconSize(QSize(icon_px, icon_px)) - else: - icon_fp = os.path.join(loc.img_dir, stripped_name + ".png") - b.setIcon(QIcon(QPixmap(icon_fp))) - b.setIconSize(QSize(self.mblen, self.mblen)) - # b.setText(name) - b.setToolTip(f"{name}") b.setCheckable(True) - if pos == 0: # make the first button selected by default + + # tooltip (name + shortcut) and the small bottom-right keycap hint + key = self._modeShortcut(name) + if key: + b.setToolTip("%s (%s)" % (name, key)) + kc = QLabel(key, b) + kf = QFont("Monospace") + kf.setStyleHint(QFont.Monospace) + kf.setPixelSize(9) + kc.setFont(kf) + kc.setAttribute(Qt.WA_TransparentForMouseEvents, True) + kc.setAlignment(Qt.AlignRight | Qt.AlignBottom) + kc.setGeometry(2, 2, self.mblen - 5, self.mblen - 4) + b.keycap = kc + else: + b.setToolTip(name) + + if pos == 0: # Pointer selected by default b.setChecked(True) - # the default-selected tool gets the active treatment (accent bg + - # white icon); refreshModeIcons re-applies it on theme/selection. - self._applyModeButtonStyle(b) - if icon_utils.has_icon(stripped_name): - b.setIcon(icon_utils.tool_icon( - stripped_name, self._mode_icon_px(), theme.ACCENT_TEXT)) b.clicked.connect(lambda : self.activateModeButton(name)) # dictionary -- name : (button object, mouse mode, position) self.mode_buttons[name] = (b, mouse_mode, pos) - # manually enter dialog function for pointer and grid + # right-click dialogs / glyph tools (unchanged behavior) if name == "Pointer": b.setRightClickEvent(self.mainwindow.modifyPointer) elif name == "Closed Trace" or name == "Open Trace": @@ -282,17 +453,137 @@ def createModeButton(self, name : str, pos : int): b.setRightClickEvent(self.mainwindow.modifyGrid) elif name == "Flag": b.setRightClickEvent(self.modifyFlag) - # manually set the flag text and color - self.setFlag() + self.setFlag() # sets the flag glyph + color elif name == "Knife": b.setRightClickEvent(self.mainwindow.modifyKnife) elif name == "Host": + # glyph tool (no SVG): size it like the 20px line icons so the + # centered ⎋ doesn't crowd the corner keycap f = b.font() - f.setPointSize(25) + f.setPointSize(15) b.setFont(f) b.setText("⎋") b.show() + return b + + def styleModeButton(self, button): + """Style one mode button for its current state (resting/hover/active) + and theme. + + Resting = transparent with a ``txt_dim`` icon; hover = faint ``panel_2`` + fill + brighter ``txt`` icon; active = accent fill + white icon (the soft + glow is painted by the card behind it). Flag/Host are glyph tools, so + their color rides on the button ``color`` rather than an icon tint. + """ + t = self._tokens + active = button.isChecked() + if active: + button.setStyleSheet( + "QPushButton { background-color: %s; border: 0px; " + "border-radius: 10px; color: %s; }" + % (theme.ACCENT, theme.ACCENT_TEXT) + ) + else: + button.setStyleSheet( + "QPushButton { background-color: transparent; border: 0px; " + "border-radius: 10px; color: %s; }" + "QPushButton:hover { background-color: %s; color: %s; }" + % (t["txt_dim"], t["panel_2"], t["txt"]) + ) + + stripped = self._stripped(button.mode_name) + if icon_utils.has_icon(stripped): + if active: + color = theme.ACCENT_TEXT + elif getattr(button, "_hovered", False): + color = t["txt"] + else: + color = t["txt_dim"] + px = self._mode_icon_px() + button.setIcon(icon_utils.tool_icon(stripped, px, color)) + button.setIconSize(QSize(px, px)) + + if button.keycap is not None: + if active: + button.keycap.setStyleSheet( + "color: rgba(255, 255, 255, 0.72); background: transparent;") + else: + button.keycap.setStyleSheet( + "color: %s; background: transparent;" % t["txt_faint"]) + + def refreshModeIcons(self): + """Re-style the tool palette for the current theme / active tool. + + Called on theme changes and after the active tool changes so the card, + icons, keycaps, dividers, and the active-tool accent all follow + light/dark (prototype: active = accent bg + white icon + glow; resting + tools at the theme icon color). + """ + self._tokens = theme.tokens() + card = getattr(self, "tool_card", None) + if card is not None: + # lighter cast shadow on light chrome (prototype's light --shadow) + shadow_alpha = 55 if theme.current_scheme() == "light" else 140 + card.applyTheme(self._tokens, theme.ACCENT, shadow_alpha=shadow_alpha) + for _name, (b, _mode, _pos) in self.mode_buttons.items(): + self.styleModeButton(b) + if card is not None: + card.update() + + def _fieldRect(self): + """Field bounds in main-window coordinates: (x1, x2, y1, y2).""" + field = self.mainwindow.field + fx1 = field.x() + fy1 = field.y() + return fx1, fx1 + field.width(), fy1, fy1 + field.height() + + def placePaletteCard(self): + """Dock the tool-palette card to a field edge, vertically centered. + + Right edge by default; the left edge when the user is left-handed. The + chosen side is mirrored into mode_x so the field's corner-text painter + keeps clear of the palette. + """ + card = getattr(self, "tool_card", None) + if card is None: + return + # width is pinned (setFixedWidth); height hugs the laid-out content. The + # widget extends past the visible body by SHADOW_MARGIN on each side, so + # dock the *body* (not the widget) to the field edge. + cw = card.width() + ch = card.sizeHint().height() + sm = card.SHADOW_MARGIN + fx1, fx2, fy1, fy2 = self._fieldRect() + margin = 14 # prototype right:14px + left_handed = bool(self.series.getOption("left_handed")) + if left_handed: + x = fx1 + margin - sm # body left -> fx1 + margin + self.mode_x = 0.01 + else: + x = fx2 - margin + sm - cw # body right -> fx2 - margin + self.mode_x = 0.99 + y = fy1 + (fy2 - fy1 - ch) / 2 + if y < fy1: + y = fy1 + card.setGeometry(int(round(x)), int(round(y)), cw, ch) + card.raise_() + + def toggleHandedness(self): + """Flip the tool palette to the other field edge and persist it.""" + self.series.setOption( + "left_handed", not bool(self.series.getOption("left_handed"))) + self.applyHandedness() + + def applyHandedness(self): + """Reposition the palette for the current left_handed setting and keep + the View-menu checkbox in sync.""" + self.placePaletteCard() + act = getattr(self.mainwindow, "lefthanded_act", None) + if act is not None: + blocked = act.blockSignals(True) + act.setChecked(bool(self.series.getOption("left_handed"))) + act.blockSignals(blocked) def placePaletteButton(self, button : PaletteButton, pos : int): """Place the palette button in the main window. @@ -792,6 +1083,8 @@ def toggleSB(self): def resetPos(self): """Reset every palette group to its default spot and forget saved positions.""" + # mode_x/mode_y are the right-docked default; resize() -> placePaletteCard() + # below re-docks the tool card to the correct edge for the handedness. self.mode_x, self.mode_y = 0.99, 0.01 self.trace_x, self.trace_y = 0.51, 0.99 self.inc_x, self.inc_y = 0.99, 0.99 @@ -922,7 +1215,9 @@ def setFlag(self, name : str = None, color : tuple = None, font_size : int = Non self.mainwindow.field.generateView(generate_image=False) button = self.mode_buttons["Flag"][0] - button.setFont(QFont("Courier New", 25, QFont.Bold)) + # glyph tool (no SVG): size the ⚑ like the 20px line icons so it sits + # cleanly beside the corner keycap + button.setFont(QFont("Courier New", 15, QFont.Bold)) button.setText("⚑") s = f"({','.join(map(str, color))})" # button.setStyleSheet(f"color:rgb{s}") @@ -987,9 +1282,7 @@ def placeSB(self): def resize(self): """Move the buttons to fit the main window.""" - for mbname in self.mode_buttons: - button, mode, pos = self.mode_buttons[mbname] - self.placeModeButton(button, pos) + self.placePaletteCard() for i, pb in enumerate(self.palette_buttons): self.placePaletteButton(pb, i) self.placePaletteSideButtons() @@ -1005,9 +1298,8 @@ def reset(self): def close(self): """Close all buttons""" - for bname in self.mode_buttons: - button, _, __ = self.mode_buttons[bname] - button.close() + # closing the card closes its child mode buttons, keycaps, and dividers + self.tool_card.close() for pb in self.palette_buttons: pb.close() for b in self.palette_side_buttons: diff --git a/PyReconstruct/modules/gui/utils/theme.py b/PyReconstruct/modules/gui/utils/theme.py index 77a3a7c7..0c8b040e 100644 --- a/PyReconstruct/modules/gui/utils/theme.py +++ b/PyReconstruct/modules/gui/utils/theme.py @@ -51,6 +51,32 @@ ICON_DARK = "#9aa7bb" # icons on dark chrome ICON_LIGHT = "#54627a" # icons on light chrome +# Floating-chrome surface tokens (the tool-palette card, its dividers, keycaps, +# and tool tooltips). This chrome floats ON TOP of the qdarkstyle base, so the +# values come from the v1 prototype's :root tokens per scheme rather than being +# derived from qdarkstyle. The resting icon color (``txt_dim``) intentionally +# matches ICON_DARK / ICON_LIGHT above. +_TOKENS = { + "dark": { + "panel": "#161b22", # card body + "panel_2": "#1d232c", # button hover fill + "elev": "#222a35", # tooltip body + "hair": "#2a313c", # card border / dividers + "txt": "#e6eaf0", # hovered icon/text + "txt_dim": ICON_DARK, # resting icon/text + "txt_faint": "#6c7889", # header + keycap + }, + "light": { + "panel": "#ffffff", + "panel_2": "#f4f6fa", + "elev": "#ffffff", + "hair": "#dbe1ea", + "txt": "#16202e", + "txt_dim": ICON_LIGHT, + "txt_faint": "#8893a6", + }, +} + # --- pure mode/scheme logic (no Qt) ------------------------------------------ def normalize_mode(value) -> str: @@ -146,6 +172,17 @@ def icon_color(scheme=None, app=None) -> str: return ICON_DARK if scheme == "dark" else ICON_LIGHT +def tokens(scheme=None, app=None) -> dict: + """Floating-chrome surface tokens (panel/hair/elev/txt…) for a scheme. + + Defaults to the scheme currently in effect. Returns the shared module dict + for the scheme — callers read from it and must not mutate it. + """ + if scheme is None: + scheme = current_scheme(app) + return _TOKENS["light"] if scheme == "light" else _TOKENS["dark"] + + # --- stylesheet construction ------------------------------------------------- # Applies to both schemes. The first two rules are the long-standing qdarkstyle # fix-ups (transparent QPushButton border; QComboBox dropdown padding), now diff --git a/tests/_palette_fixture.py b/tests/_palette_fixture.py new file mode 100644 index 00000000..13c616ab --- /dev/null +++ b/tests/_palette_fixture.py @@ -0,0 +1,152 @@ +"""Headless fixture for the floating tool palette. + +Builds a real :class:`MousePalette` tool card against a minimal fake main +window + series, without dragging in the heavy trace-palette / scale-bar / +brightness machinery. Both ``test_tool_palette.py`` and the offscreen preview +script use this, so the preview renders the *same* code the tests cover. +""" +from types import SimpleNamespace + +from PySide6.QtWidgets import QApplication, QWidget +from PySide6.QtGui import QAction, QPainter, QColor, QRadialGradient, QBrush +from PySide6.QtCore import Qt, QRectF + +from PyReconstruct.modules.gui.palette.mouse_palette import MousePalette +from PyReconstruct.modules.gui.utils import theme + + +# Sensible stand-ins for the series options the tool card reads. Shortcut keys +# mirror PyReconstruct/modules/datatypes/default_settings.py so the keycaps in +# the preview match what the app actually shows. +_DEFAULTS = { + "left_handed": False, + "usepointer_act": "P", + "usepanzoom_act": "Z", + "useknife_act": "K", + "usectrace_act": "C", + "useotrace_act": "O", + "usestamp_act": "S", + "usegrid_act": "G", + "useflag_act": "F", + "usehost_act": "Q", + "flag_name": "flag", + "flag_color": (255, 80, 80), + "flag_size": 14, + "show_flags": "all", +} + + +class FakeSeries: + def __init__(self, opts=None): + self.opts = dict(opts or {}) + + def getOption(self, key, *args): + if key in self.opts: + return self.opts[key] + return _DEFAULTS.get(key) + + def setOption(self, key, value): + self.opts[key] = value + + +class _FieldBg(QWidget): + """A stand-in field that paints an EM-like backdrop, so the translucent + palette card reads against realistic content in the preview.""" + + def __init__(self, parent, scheme): + super().__init__(parent) + self._scheme = scheme + + def paintEvent(self, event): + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + r = self.rect() + grad = QRadialGradient(r.center(), max(r.width(), r.height()) * 0.7) + if self._scheme == "light": + grad.setColorAt(0.0, QColor("#e9eef7")) + grad.setColorAt(0.7, QColor("#d6deea")) + grad.setColorAt(1.0, QColor("#c4cedd")) + blobs = [("#9ec5ff", 0.20), ("#ffd0e2", 0.18)] + else: + grad.setColorAt(0.0, QColor("#1a222e")) + grad.setColorAt(0.68, QColor("#0d1219")) + grad.setColorAt(1.0, QColor("#05070a")) + blobs = [("#37c6ff", 0.16), ("#ff5d9e", 0.16), ("#42e08b", 0.14)] + p.fillRect(r, QBrush(grad)) + # a few faint "cells" so the card has texture behind it + cx, cy, w, h = r.x(), r.y(), r.width(), r.height() + spots = [(0.30, 0.40, 0.26), (0.62, 0.62, 0.30), (0.50, 0.22, 0.18)] + for (fx, fy, fr), (col, alpha) in zip(spots, blobs * 2): + c = QColor(col) + c.setAlphaF(alpha) + rad = fr * min(w, h) + p.setBrush(c) + p.setPen(Qt.NoPen) + p.drawEllipse(QRectF(cx + fx * w - rad, cy + fy * h - rad * 0.8, + rad * 2, rad * 1.6)) + p.end() + + +class FakeMainWindow(QWidget): + def __init__(self, series, field_size, scheme): + super().__init__() + self.series = series + self.resize(field_size[0], field_size[1]) + self.field = _FieldBg(self, scheme) + self.field.setGeometry(0, 0, field_size[0], field_size[1]) + self.field.section = SimpleNamespace( + selected_flags=[], brightness=0, contrast=0) + self.lefthanded_act = QAction(self) + self.lefthanded_act.setCheckable(True) + self.last_mouse_mode = None + + # methods the tool card may call + def changeMouseMode(self, mode): + self.last_mouse_mode = mode + + def modifyPointer(self): + pass + + def changeTraceMode(self): + pass + + def modifyGrid(self): + pass + + def modifyKnife(self): + pass + + +def make_palette(left_handed=False, scheme=None, field_size=(900, 600)): + """Build a tool-palette card headlessly. + + Returns ``(app, mainwindow, palette, restore)``. ``scheme`` (``"dark"`` / + ``"light"``) pins the rendered theme by patching ``theme.current_scheme``; + call the returned ``restore()`` (or ``None``) when finished to undo it. + """ + app = QApplication.instance() or QApplication([]) + + restore = None + if scheme is not None: + _orig = theme.current_scheme + theme.current_scheme = lambda *a, **k: scheme + + def restore(): + theme.current_scheme = _orig + + series = FakeSeries({"left_handed": left_handed}) + mw = FakeMainWindow(series, field_size, scheme or "dark") + + mp = MousePalette.__new__(MousePalette) + mp.mainwindow = mw + mp.series = series + mp.mblen = 38 + mp.is_dragging = False + mp._tokens = theme.tokens() + mp.mode_x = 0.99 + mp.mode_y = 0.01 + mp.mode_buttons = {} + mp.selected_mode = "pointer" + mp.createPaletteCard() + + return app, mw, mp, restore diff --git a/tests/test_tool_palette.py b/tests/test_tool_palette.py new file mode 100644 index 00000000..eb189622 --- /dev/null +++ b/tests/test_tool_palette.py @@ -0,0 +1,213 @@ +"""Headless tests for the floating tool palette (right-edge docked card). + +Covers the logic that does not need a full main window: card structure, the +right-edge / left-handed docking + flip, the active-tool state, keycap hints, +and the theme tokens. Rendering itself is exercised by a grab() smoke test; +the visual review is the preview artifact. + +Run: PYTHONPATH= QT_QPA_PLATFORM=offscreen pytest -q tests/test_tool_palette.py +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) +from _palette_fixture import make_palette # noqa: E402 + +from PyReconstruct.modules.gui.utils import theme # noqa: E402 +from PyReconstruct.modules.gui.palette.mouse_palette import PaletteCard # noqa: E402 + +SM = PaletteCard.SHADOW_MARGIN # transparent shadow padding around the body + + +MODE_TO_INT = { + "Pointer": 0, "Pan/Zoom": 1, "Knife": 2, "Scissors": 3, + "Closed Trace": 4, "Open Trace": 5, "Stamp": 6, "Grid": 7, + "Flag": 8, "Host": 9, "Ztool": 10, +} +EXPECTED_KEYS = { + "Pointer": "P", "Pan/Zoom": "Z", "Knife": "K", "Closed Trace": "C", + "Open Trace": "O", "Stamp": "S", "Grid": "G", "Flag": "F", "Host": "Q", +} +NO_KEY = ("Scissors", "Ztool") + + +# ---------------------------------------------------------------- structure + +def test_card_structure(): + _app, _mw, mp, _r = make_palette() + assert len(mp.mode_buttons) == 11 + # four tool families -> three dividers + assert len(mp.tool_card.dividers) == 3 + assert mp.tool_card.header is not None + assert mp.tool_card.header.text() == "TOOLS" + # all 11 buttons are tracked on the card for the active-halo paint + assert len(mp.tool_card.tool_buttons) == 11 + + +def test_card_width_matches_prototype(): + # prototype --pal-w is 52px: 38px tool + 7px padding each side (the widget + # carries an extra transparent SHADOW_MARGIN on each side for the shadow) + _app, _mw, mp, _r = make_palette() + assert mp.tool_card.width() - 2 * SM == 52 + assert mp.tool_card.bodyRect().width() == 52 + for b, _m, _p in mp.mode_buttons.values(): + assert b.width() == 38 and b.height() == 38 + + +def test_buttons_not_draggable(): + # docked mode buttons must not free-drag (the card edge-docks/flips instead) + _app, _mw, mp, _r = make_palette() + b = mp.mode_buttons["Pointer"][0] + # ModeButton overrides mouseMoveEvent to a no-op + assert b.__class__.mouseMoveEvent is not b.__class__.__mro__[1].mouseMoveEvent + + +# --------------------------------------------------------------- active tool + +def test_pointer_active_by_default(): + _app, _mw, mp, _r = make_palette() + assert mp.mode_buttons["Pointer"][0].isChecked() + for name, (b, _m, _p) in mp.mode_buttons.items(): + if name != "Pointer": + assert not b.isChecked(), name + + +def test_activate_switches_exclusive_and_sets_mode(): + _app, mw, mp, _r = make_palette() + for name, expect_int in MODE_TO_INT.items(): + mp.activateModeButton(name) + assert mp.mode_buttons[name][0].isChecked(), name + # exactly one checked + checked = [n for n, (b, _m, _p) in mp.mode_buttons.items() if b.isChecked()] + assert checked == [name], checked + assert mw.last_mouse_mode == expect_int, (name, mw.last_mouse_mode) + assert mp.selected_mode == name + + +def test_active_styling_is_accent_resting_is_transparent(): + _app, _mw, mp, restore = make_palette(scheme="dark") + try: + mp.activateModeButton("Closed Trace") + active_qss = mp.mode_buttons["Closed Trace"][0].styleSheet() + resting_qss = mp.mode_buttons["Pointer"][0].styleSheet() + assert theme.ACCENT in active_qss + assert theme.ACCENT_TEXT in active_qss + assert "transparent" in resting_qss + assert ":hover" in resting_qss # hover affordance present on resting + assert theme.ACCENT not in resting_qss + finally: + if restore: + restore() + + +def test_active_keycap_turns_white(): + _app, _mw, mp, _r = make_palette() + mp.activateModeButton("Pointer") + kc = mp.mode_buttons["Pointer"][0].keycap + assert "255, 255, 255" in kc.styleSheet() + + +# ------------------------------------------------------------------ keycaps + +def test_keycaps_match_live_shortcuts(): + _app, _mw, mp, _r = make_palette() + for name, key in EXPECTED_KEYS.items(): + b = mp.mode_buttons[name][0] + assert b.keycap is not None, name + assert b.keycap.text() == key, name + assert key in b.toolTip() and name in b.toolTip(), name + + +def test_modes_without_shortcut_have_no_keycap(): + _app, _mw, mp, _r = make_palette() + for name in NO_KEY: + b = mp.mode_buttons[name][0] + assert b.keycap is None, name + assert b.toolTip() == name + + +def test_keycap_follows_series_override(): + # a user-overridden shortcut must show on the keycap, not the default + _app, _mw, mp, _r = make_palette() + mp.series.setOption("usepointer_act", "A") + # rebuild the card so it reflects the override + mp.tool_card.close() + mp.mode_buttons = {} + mp.createPaletteCard() + assert mp.mode_buttons["Pointer"][0].keycap.text() == "A" + + +# ------------------------------------------------------- docking / handedness + +def test_docks_right_by_default(): + _app, mw, mp, _r = make_palette(left_handed=False) + assert mp.mode_x == 0.99 # field corner-text painter reads this (>.5 == right) + g = mp.tool_card.geometry() + field_right = mw.field.x() + mw.field.width() + # the visible body (widget minus shadow margin) sits at the right edge + body_right = g.x() + g.width() - SM + assert abs(body_right - (field_right - 14)) <= 1 + # vertically centered + assert abs(g.y() - (mw.field.height() - g.height()) / 2) <= 2 + + +def test_docks_left_when_left_handed(): + _app, mw, mp, _r = make_palette(left_handed=True) + assert mp.mode_x == 0.01 # (<=.5 == left) + g = mp.tool_card.geometry() + body_left = g.x() + SM + assert abs(body_left - (mw.field.x() + 14)) <= 1 + + +def test_toggle_handedness_flips_persists_and_syncs_menu(): + _app, mw, mp, _r = make_palette(left_handed=False) + right_x = mp.tool_card.geometry().x() + + mp.toggleHandedness() + assert mp.series.getOption("left_handed") is True + assert mp.mode_x == 0.01 + left_x = mp.tool_card.geometry().x() + assert left_x < right_x + assert mw.lefthanded_act.isChecked() is True + + mp.toggleHandedness() + assert mp.series.getOption("left_handed") is False + assert mp.mode_x == 0.99 + assert abs(mp.tool_card.geometry().x() - right_x) <= 1 + assert mw.lefthanded_act.isChecked() is False + + +def test_apply_handedness_without_menu_action_is_safe(): + _app, mw, mp, _r = make_palette() + del mw.lefthanded_act # guarded by getattr in applyHandedness + mp.series.setOption("left_handed", True) + mp.applyHandedness() # must not raise + assert mp.mode_x == 0.01 + + +# -------------------------------------------------------------------- render + +def test_card_grabs_to_pixmap(): + _app, _mw, mp, _r = make_palette() + pm = mp.tool_card.grab() + assert not pm.isNull() + assert pm.width() == mp.tool_card.width() + + +# -------------------------------------------------------------------- tokens + +def test_theme_tokens_distinct_per_scheme(): + dark = theme.tokens("dark") + light = theme.tokens("light") + for key in ("panel", "panel_2", "elev", "hair", "txt", "txt_dim", "txt_faint"): + assert key in dark and key in light + assert dark[key] != light[key], key + # resting icon color stays consistent with icon_color() + assert dark["txt_dim"] == theme.ICON_DARK + assert light["txt_dim"] == theme.ICON_LIGHT + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"]))