diff --git a/negpy/desktop/view/canvas/gpu_widget.py b/negpy/desktop/view/canvas/gpu_widget.py index ca671f43..0d3e155d 100644 --- a/negpy/desktop/view/canvas/gpu_widget.py +++ b/negpy/desktop/view/canvas/gpu_widget.py @@ -184,6 +184,17 @@ def _upload_display_lut(self) -> None: address_mode_w=wgpu.AddressMode.clamp_to_edge, ) + def presents_to_screen(self) -> bool: + """True when the frame goes to a native surface of its own, which the overlay + must punch an alpha hole to reveal. + + rendercanvas picks a *bitmap* present on Qt: the frame is blitted into the + shared backing store by the widget's own paintEvent, and a hole punched over + it erases the frame instead of revealing anything. + """ + sub = getattr(self.canvas, "_subwidget", self.canvas) + return bool(getattr(sub, "_present_to_screen", False)) + def set_transform(self, zoom: float, px: float, py: float) -> None: self.zoom = zoom self.pan_x = px diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 60a9aa5f..3d0b5640 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -571,15 +571,20 @@ def paintEvent(self, event) -> None: painter = QPainter(self) parent_bg = getattr(self.parent(), "_bg_color", QColor("#050505")) - if not getattr(self.parent(), "gpu_widget", None) or not self.parent().gpu_widget.isVisible(): + gpu = getattr(self.parent(), "gpu_widget", None) + gpu_live = bool(gpu is not None and gpu.isVisible()) + if not gpu_live: painter.fillRect(event.rect(), parent_bg) if sys.platform in ("darwin", "win32"): painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - if getattr(self.parent(), "gpu_widget", None) and self.parent().gpu_widget.isVisible(): - painter.fillRect(event.rect(), Qt.GlobalColor.transparent) - else: + if not gpu_live: painter.fillRect(event.rect(), parent_bg) + elif gpu.presents_to_screen(): + # Only a native surface underneath is revealed by the hole. Under a + # bitmap present the frame is in this very backing store, and the + # fill wipes it (issue: white canvas on macOS, black on Windows). + painter.fillRect(event.rect(), Qt.GlobalColor.transparent) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) painter.setRenderHint(QPainter.RenderHint.Antialiasing) diff --git a/tests/test_overlay_gpu_compositing.py b/tests/test_overlay_gpu_compositing.py new file mode 100644 index 00000000..1ca951b6 --- /dev/null +++ b/tests/test_overlay_gpu_compositing.py @@ -0,0 +1,93 @@ +"""The overlay must not erase the frame the GPU canvas painted underneath it. + +rendercanvas presents through a bitmap on Qt: the canvas blits its frame into the +shared backing store. The alpha hole the overlay punches on macOS/Windows was written +for a native surface underneath; over a bitmap present it wipes the frame, and the +canvas shows the bare window background (white on macOS, black on Windows). +""" + +import sys + +import pytest +from PyQt6.QtGui import QColor, QImage +from PyQt6.QtWidgets import QWidget + +from negpy.desktop.session import AppState +from negpy.desktop.view.canvas.overlay import CanvasOverlay + +W, H = 60, 40 +FRAME = QColor(20, 160, 90) + + +class _FakeGPUWidget: + def __init__(self, visible: bool, to_screen: bool): + self._visible, self._to_screen = visible, to_screen + + def isVisible(self) -> bool: + return self._visible + + def presents_to_screen(self) -> bool: + return self._to_screen + + +class _FakeCanvas(QWidget): + """Stands in for ImageCanvas: owns the background colour and the GPU widget.""" + + def __init__(self, gpu): + super().__init__() + self._bg_color = QColor("#050505") + self.gpu_widget = gpu + + +def _paint_over_frame(gpu) -> QImage: + """Paint the overlay onto a surface that already holds a rendered frame.""" + canvas = _FakeCanvas(gpu) + overlay = CanvasOverlay(AppState(), canvas) + overlay.resize(W, H) + + surface = QImage(W, H, QImage.Format.Format_ARGB32_Premultiplied) + surface.fill(FRAME) + overlay.render(surface) + return surface + + +@pytest.mark.skipif(sys.platform not in ("darwin", "win32"), reason="the alpha hole is macOS/Windows only") +def test_bitmap_present_frame_survives_the_overlay(): + surface = _paint_over_frame(_FakeGPUWidget(visible=True, to_screen=False)) + + assert QColor(surface.pixel(W // 2, H // 2)) == FRAME + + +@pytest.mark.skipif(sys.platform not in ("darwin", "win32"), reason="the alpha hole is macOS/Windows only") +def test_screen_present_still_gets_its_hole(): + surface = _paint_over_frame(_FakeGPUWidget(visible=True, to_screen=True)) + + assert QColor.fromRgba(surface.pixel(W // 2, H // 2)).alpha() == 0 + + +def test_hidden_gpu_widget_gets_the_canvas_background(): + surface = _paint_over_frame(_FakeGPUWidget(visible=False, to_screen=False)) + + assert QColor(surface.pixel(W // 2, H // 2)) == QColor("#050505") + + +def test_presents_to_screen_reads_the_canvas_present_method(): + from negpy.desktop.view.canvas.gpu_widget import GPUCanvasWidget + + widget = GPUCanvasWidget.__new__(GPUCanvasWidget) + + class _Sub: + _present_to_screen = False + + class _Canvas: + _subwidget = _Sub() + + widget.canvas = _Canvas() + assert widget.presents_to_screen() is False + + _Sub._present_to_screen = True + assert widget.presents_to_screen() is True + + # Before get_context() the method is unresolved; no hole until it is known. + _Sub._present_to_screen = None + assert widget.presents_to_screen() is False