classes.c90_render_view

  1import objc
  2import vanilla
  3from vanilla import dialogs
  4from vanilla.test.testTools import executeVanillaTest
  5import drawBot
  6from drawBot.ui.drawView import DrawView
  7from AppKit import (
  8    NSObject,
  9    NSScreen,
 10    NSEvent,
 11)
 12from typing import Any, Callable
 13from time import perf_counter
 14from loguru import logger
 15from lib import layout
 16import sys
 17import inspect
 18from classes.c91_render_panel_manager import RenderPanelManager
 19
 20
 21class _PaintDebouncer(NSObject):
 22    """Cocoa-native debouncer that delays a callback using the main run loop."""
 23
 24    @objc.python_method
 25    def setup(self, callback):
 26        self._callback = callback
 27        return self
 28
 29    @objc.python_method
 30    def schedule(self, delay: float):
 31        """Schedule callback after delay, cancelling any pending invocation."""
 32        type(self).cancelPreviousPerformRequestsWithTarget_selector_object_(
 33            self, "fire:", None
 34        )
 35        self.performSelector_withObject_afterDelay_("fire:", None, delay)
 36
 37    @objc.python_method
 38    def cancel(self):
 39        """Cancel any pending invocation."""
 40        type(self).cancelPreviousPerformRequestsWithTarget_selector_object_(
 41            self, "fire:", None
 42        )
 43
 44    def fire_(self, _):
 45        if hasattr(self, "_callback") and self._callback:
 46            self._callback()
 47
 48
 49class RenderView:
 50    """Execute a function in a GUI window using DrawBot and Vanilla."""
 51
 52    UI_PANEL_HEIGHT = 26
 53    AUTO_FIT_MARGIN = 0.992  # Keep a safety margin to avoid overflow scrollbars.
 54
 55    @staticmethod
 56    def _isRunningInDrawBotApp() -> bool:
 57        """Detect whether this code is running inside DrawBot.app."""
 58        executable = (sys.executable or "").lower()
 59        argv0 = (sys.argv[0] if sys.argv else "").lower()
 60        drawbot_file = (getattr(drawBot, "__file__", "") or "").lower()
 61        return (
 62            "drawbot.app/contents/macos/drawbot" in executable
 63            or executable.endswith("/drawbot")
 64            or "drawbot.app" in argv0
 65            or "drawbot.app" in drawbot_file
 66        )
 67
 68    def __init__(
 69        self,
 70        func: Callable,
 71        windowRatio: layout.AspectRatioInput = 2 / 3,
 72        windowScale: int = 1,
 73        canvasScale: int = 1,
 74        ui: bool = True,
 75        panelManager: RenderPanelManager | None = None,
 76        debounceDelay: float = 0.5,
 77    ):
 78        """
 79        Initialize a GUI environment.
 80
 81        Args:
 82            func: The function to execute and display in the GUI.
 83            windowRatio: The width/height ratio of the window.
 84            windowScale: The scaling factor for the window size relative to the screen. (0-1)
 85            canvasScale: The scale factor for the canvas rendering.
 86            ui: Whether to show UI controls (refresh and save buttons).
 87            panelManager: Optional side panel manager object.
 88            debounceDelay: Seconds to wait after a panel change before re-rendering (default: 0.5).
 89        """
 90        self._windowRatio = layout.parseAspectRatio(windowRatio)
 91        if windowScale <= 0 or windowScale > 1:
 92            raise ValueError(
 93                "windowScale must be between 0 (exclusive) and 1 (inclusive)."
 94            )
 95        self._windowScale = windowScale
 96        self._canvasScale = canvasScale
 97        self._ui = ui
 98        self._panelManager = panelManager
 99        self._debounceDelay = debounceDelay
100        self._lastRenderedState: dict | None = None
101        self._lastRenderedPDF = None
102        self._debouncer: _PaintDebouncer | None = None
103
104        if self._isRunningInDrawBotApp():
105            self.run(func)
106        else:
107            executeVanillaTest(self.run(func))
108
109    def getPanelState(self) -> dict[str, Any]:
110        """Return active panel plugin state, or an empty dict."""
111        if self._panelManager is None:
112            return {}
113        return self._panelManager.getState()
114
115    def _invokeRenderFunction(self, func: Callable):
116        """Call renderer with optional panel state if function accepts arguments."""
117        if self._panelManager is None:
118            return func()
119
120        panelState = self.getPanelState()
121        try:
122            sig = inspect.signature(func)
123            params = list(sig.parameters.values())
124        except (TypeError, ValueError):
125            return func()
126
127        if not params:
128            return func()
129
130        first = params[0]
131        if first.kind in (
132            inspect.Parameter.POSITIONAL_ONLY,
133            inspect.Parameter.POSITIONAL_OR_KEYWORD,
134            inspect.Parameter.VAR_POSITIONAL,
135        ):
136            return func(panelState)
137
138        return func()
139
140    def run(self, func):
141        """
142        Set up and run the GUI window, rendering the provided function.
143
144        Args:
145            func: The function to execute and render on the canvas.
146        """
147
148        def getPlacementScreen():
149            """Determine which screen the mouse is currently on to place the window there."""
150            if self._isRunningInDrawBotApp():
151                logger.debug("DrawBot.app mode: using main screen (no mouse probe)")
152                return NSScreen.mainScreen()
153            try:
154                mouse = NSEvent.mouseLocation()
155                screens = NSScreen.screens()
156                for screen in screens:
157                    frame = screen.frame()
158                    left = frame.origin.x
159                    bottom = frame.origin.y
160                    right = left + frame.size.width
161                    top = bottom + frame.size.height
162                    if left <= mouse.x <= right and bottom <= mouse.y <= top:
163                        return screen
164            except Exception as e:
165                logger.warning(
166                    f"Failed to get placement screen from mouse position: {e}"
167                )
168            return NSScreen.mainScreen()
169
170        def getWindowChromeHeight():
171            """Measure current window chrome height (frame minus content) in points."""
172            if self._isRunningInDrawBotApp():
173                logger.debug(
174                    "DrawBot.app mode: skipping chrome probe, using fallback 28"
175                )
176                return 28
177
178            probe = None
179            try:
180                probe = vanilla.Window((200, 200))
181                nsWindow = probe.getNSWindow()
182                frame = nsWindow.frame()
183                content = nsWindow.contentRectForFrameRect_(frame)
184                return frame.size.height - content.size.height
185            except Exception as e:
186                logger.warning(
187                    f"Failed to measure window chrome height, using fallback 28: {e}"
188                )
189                return 28
190            finally:
191                if probe is not None:
192                    try:
193                        probe.close()
194                    except Exception:
195                        pass
196
197        windowChromeH = getWindowChromeHeight()
198
199        def getScreenSize():
200            """
201            Calculate the window size based on usable screen area (without MacOS menu bar and dock) and windowRatio/windowScale.
202
203            Returns:
204                tuple: (window width, window height)
205            """
206            try:
207                visible = getPlacementScreen().visibleFrame()
208                availW, availH = visible.size.width, visible.size.height
209
210                contentAvailH = (
211                    availH - windowChromeH
212                )  # Subtract space for OS window chrome
213                if self._ui:
214                    contentAvailH -= self.UI_PANEL_HEIGHT  # Subtract space for UI panel
215
216                # Use usable width/height so left/right dock positions are accounted for.
217                screenAspectRatio = availW / contentAvailH
218
219                isWiderThanScreen = self._windowRatio > screenAspectRatio
220
221                if isWiderThanScreen:
222                    w = availW * self._windowScale
223                    h = w / self._windowRatio
224                else:
225                    h = contentAvailH * self._windowScale
226                    w = h * self._windowRatio
227
228                h += windowChromeH  # Add back space for OS window chrome
229                if self._ui:
230                    h += self.UI_PANEL_HEIGHT  # Add back space for UI panel
231
232                if self._panelManager is not None:
233                    w += self._panelManager.getRightInset()
234
235                return w, h
236            except Exception as e:
237                logger.warning(f"Failed to compute screen-based window size: {e}")
238                return 900, 600
239
240        def paintCanvas(sender=None):
241            """
242            Render the function output to the canvas and update the PDF document.
243            """
244            start = perf_counter()
245
246            # Phase 2: Skip expensive PDF re-render if panel state is unchanged.
247            currentState = self._getRenderableState()
248            if currentState != self._lastRenderedState:
249                logger.trace("Panel state changed — re-rendering PDF")
250                drawBot.newDrawing()
251                self._invokeRenderFunction(func)
252                self._lastRenderedPDF = drawBot.pdfImage()
253                drawBot.endDrawing()
254                self._lastRenderedState = currentState
255            else:
256                logger.trace("State unchanged — skipping PDF render")
257
258            # Set the unflipped PDF directly — flip is handled via layer transform below.
259            if self._lastRenderedPDF is not None:
260                logger.trace("Updating canvas PDF document")
261                self.w.canvas.setPDFDocument(self._lastRenderedPDF)
262
263            # Always re-fit scale: canvas area may have changed (e.g. panel toggle).
264            nsView = self.w.canvas.getNSView()
265            fitScale = nsView.scaleFactorForSizeToFit()
266            targetScale = (
267                fitScale * self.AUTO_FIT_MARGIN
268                if self._canvasScale == 1
269                else self._canvasScale
270            )
271            self.w.canvas.setScale(targetScale)
272
273            # Apply flip AFTER setScale so the layer transform isn't overwritten.
274            panelState = self.getPanelState()
275            flipX = bool(panelState.get("flipX", False))
276            flipY = bool(panelState.get("flipY", False))
277            self._applyFlipTransform(flipX, flipY)
278
279            logger.trace(
280                "Canvas fit metrics: targetScale={:.4f}, fitScale={:.4f}, canvasScale={:.4f}",
281                targetScale,
282                fitScale,
283                self._canvasScale,
284            )
285
286            elapsed = perf_counter() - start
287            logger.info(f"⏱️ Paint took {elapsed:.4f} seconds")
288
289        def forceRender(sender=None):
290            """Force a full re-render, bypassing the state-diff optimisation."""
291            self._debouncer.cancel()
292            self._lastRenderedState = None
293            paintCanvas()
294
295        # Phase 1: set up Cocoa-native debouncer for panel changes.
296        self._debouncer = _PaintDebouncer.alloc().init()
297        self._debouncer.setup(paintCanvas)
298
299        def onPanelChanged(immediate=False):
300            if self._panelManager is not None:
301                self._panelManager.applyUILayout()
302            if immediate:
303                self._debouncer.cancel()
304                paintCanvas()
305            else:
306                self._debouncer.schedule(self._debounceDelay)
307
308        self.w = vanilla.Window(
309            getScreenSize(),
310            minSize=(400, 400),
311        )
312
313        if self._ui:
314            self.w.refreshBtn = vanilla.Button(
315                (-196, -22, 60, 14),
316                "Refresh",
317                sizeStyle="mini",
318                callback=forceRender,
319            )
320            self.w.saveBtn = vanilla.Button(
321                (-132, -22, 60, 14),
322                "Save File",
323                sizeStyle="mini",
324                callback=self.savePdf,
325            )
326            self.w.printBtn = vanilla.Button(
327                (-68, -22, 60, 14),
328                "Print",
329                sizeStyle="mini",
330                callback=self.printImage,
331            )
332            canvasH = -self.UI_PANEL_HEIGHT
333        else:
334            canvasH = -0
335
336        self.w.canvas = DrawView((0, 0, -0, canvasH))
337
338        if self._panelManager is not None:
339            self._panelManager.build(
340                self.w,
341                canvasH,
342                onPanelChanged,
343                onForceRender=forceRender,
344            )
345
346        paintCanvas()
347
348        self.w.open()
349        self.w.center()
350
351    def _getRenderableState(self) -> dict:
352        """Panel state excluding view-transform keys that don't affect PDF content."""
353        state = self.getPanelState()
354        return {k: v for k, v in state.items() if k not in ("flipX", "flipY")}
355
356    def _applyFlipTransform(self, flipX: bool, flipY: bool):
357        """Flip the canvas view via a CALayer affine transform — no PDF rewriting."""
358        try:
359            from Quartz import CGAffineTransformMakeScale
360
361            nsView = self.w.canvas.getNSView()
362            nsView.setWantsLayer_(True)
363            layer = nsView.layer()
364            if layer is None:
365                return
366
367            # Compute the layer's frame center in parent coords regardless of the
368            # current anchor point (AppKit-backed layers often anchor at (0,0)).
369            bounds = layer.bounds()
370            pos = layer.position()
371            anchor = layer.anchorPoint()
372            bw, bh = bounds.size.width, bounds.size.height
373            cx = (pos.x - anchor.x * bw) + bw / 2.0
374            cy = (pos.y - anchor.y * bh) + bh / 2.0
375
376            # Pin anchor to center so scale(±1) flips around the middle of the view.
377            layer.setAnchorPoint_((0.5, 0.5))
378            layer.setPosition_((cx, cy))
379
380            sx = -1.0 if flipX else 1.0
381            sy = -1.0 if flipY else 1.0
382            layer.setAffineTransform_(CGAffineTransformMakeScale(sx, sy))
383        except Exception as e:
384            logger.warning(f"Failed to apply flip transform: {e}")
385
386    def savePdf(self, sender):
387        """
388        Save the current canvas as a PDF, SVG, or PNG file.
389        """
390        path = dialogs.putFile(fileTypes=["pdf", "svg", "png"])
391        if path:
392            drawBot.saveImage(path)
393
394    def printImage(self, sender):
395        """Send the current drawing to the system print dialog."""
396        drawBot.printImage()
class RenderView:
 50class RenderView:
 51    """Execute a function in a GUI window using DrawBot and Vanilla."""
 52
 53    UI_PANEL_HEIGHT = 26
 54    AUTO_FIT_MARGIN = 0.992  # Keep a safety margin to avoid overflow scrollbars.
 55
 56    @staticmethod
 57    def _isRunningInDrawBotApp() -> bool:
 58        """Detect whether this code is running inside DrawBot.app."""
 59        executable = (sys.executable or "").lower()
 60        argv0 = (sys.argv[0] if sys.argv else "").lower()
 61        drawbot_file = (getattr(drawBot, "__file__", "") or "").lower()
 62        return (
 63            "drawbot.app/contents/macos/drawbot" in executable
 64            or executable.endswith("/drawbot")
 65            or "drawbot.app" in argv0
 66            or "drawbot.app" in drawbot_file
 67        )
 68
 69    def __init__(
 70        self,
 71        func: Callable,
 72        windowRatio: layout.AspectRatioInput = 2 / 3,
 73        windowScale: int = 1,
 74        canvasScale: int = 1,
 75        ui: bool = True,
 76        panelManager: RenderPanelManager | None = None,
 77        debounceDelay: float = 0.5,
 78    ):
 79        """
 80        Initialize a GUI environment.
 81
 82        Args:
 83            func: The function to execute and display in the GUI.
 84            windowRatio: The width/height ratio of the window.
 85            windowScale: The scaling factor for the window size relative to the screen. (0-1)
 86            canvasScale: The scale factor for the canvas rendering.
 87            ui: Whether to show UI controls (refresh and save buttons).
 88            panelManager: Optional side panel manager object.
 89            debounceDelay: Seconds to wait after a panel change before re-rendering (default: 0.5).
 90        """
 91        self._windowRatio = layout.parseAspectRatio(windowRatio)
 92        if windowScale <= 0 or windowScale > 1:
 93            raise ValueError(
 94                "windowScale must be between 0 (exclusive) and 1 (inclusive)."
 95            )
 96        self._windowScale = windowScale
 97        self._canvasScale = canvasScale
 98        self._ui = ui
 99        self._panelManager = panelManager
100        self._debounceDelay = debounceDelay
101        self._lastRenderedState: dict | None = None
102        self._lastRenderedPDF = None
103        self._debouncer: _PaintDebouncer | None = None
104
105        if self._isRunningInDrawBotApp():
106            self.run(func)
107        else:
108            executeVanillaTest(self.run(func))
109
110    def getPanelState(self) -> dict[str, Any]:
111        """Return active panel plugin state, or an empty dict."""
112        if self._panelManager is None:
113            return {}
114        return self._panelManager.getState()
115
116    def _invokeRenderFunction(self, func: Callable):
117        """Call renderer with optional panel state if function accepts arguments."""
118        if self._panelManager is None:
119            return func()
120
121        panelState = self.getPanelState()
122        try:
123            sig = inspect.signature(func)
124            params = list(sig.parameters.values())
125        except (TypeError, ValueError):
126            return func()
127
128        if not params:
129            return func()
130
131        first = params[0]
132        if first.kind in (
133            inspect.Parameter.POSITIONAL_ONLY,
134            inspect.Parameter.POSITIONAL_OR_KEYWORD,
135            inspect.Parameter.VAR_POSITIONAL,
136        ):
137            return func(panelState)
138
139        return func()
140
141    def run(self, func):
142        """
143        Set up and run the GUI window, rendering the provided function.
144
145        Args:
146            func: The function to execute and render on the canvas.
147        """
148
149        def getPlacementScreen():
150            """Determine which screen the mouse is currently on to place the window there."""
151            if self._isRunningInDrawBotApp():
152                logger.debug("DrawBot.app mode: using main screen (no mouse probe)")
153                return NSScreen.mainScreen()
154            try:
155                mouse = NSEvent.mouseLocation()
156                screens = NSScreen.screens()
157                for screen in screens:
158                    frame = screen.frame()
159                    left = frame.origin.x
160                    bottom = frame.origin.y
161                    right = left + frame.size.width
162                    top = bottom + frame.size.height
163                    if left <= mouse.x <= right and bottom <= mouse.y <= top:
164                        return screen
165            except Exception as e:
166                logger.warning(
167                    f"Failed to get placement screen from mouse position: {e}"
168                )
169            return NSScreen.mainScreen()
170
171        def getWindowChromeHeight():
172            """Measure current window chrome height (frame minus content) in points."""
173            if self._isRunningInDrawBotApp():
174                logger.debug(
175                    "DrawBot.app mode: skipping chrome probe, using fallback 28"
176                )
177                return 28
178
179            probe = None
180            try:
181                probe = vanilla.Window((200, 200))
182                nsWindow = probe.getNSWindow()
183                frame = nsWindow.frame()
184                content = nsWindow.contentRectForFrameRect_(frame)
185                return frame.size.height - content.size.height
186            except Exception as e:
187                logger.warning(
188                    f"Failed to measure window chrome height, using fallback 28: {e}"
189                )
190                return 28
191            finally:
192                if probe is not None:
193                    try:
194                        probe.close()
195                    except Exception:
196                        pass
197
198        windowChromeH = getWindowChromeHeight()
199
200        def getScreenSize():
201            """
202            Calculate the window size based on usable screen area (without MacOS menu bar and dock) and windowRatio/windowScale.
203
204            Returns:
205                tuple: (window width, window height)
206            """
207            try:
208                visible = getPlacementScreen().visibleFrame()
209                availW, availH = visible.size.width, visible.size.height
210
211                contentAvailH = (
212                    availH - windowChromeH
213                )  # Subtract space for OS window chrome
214                if self._ui:
215                    contentAvailH -= self.UI_PANEL_HEIGHT  # Subtract space for UI panel
216
217                # Use usable width/height so left/right dock positions are accounted for.
218                screenAspectRatio = availW / contentAvailH
219
220                isWiderThanScreen = self._windowRatio > screenAspectRatio
221
222                if isWiderThanScreen:
223                    w = availW * self._windowScale
224                    h = w / self._windowRatio
225                else:
226                    h = contentAvailH * self._windowScale
227                    w = h * self._windowRatio
228
229                h += windowChromeH  # Add back space for OS window chrome
230                if self._ui:
231                    h += self.UI_PANEL_HEIGHT  # Add back space for UI panel
232
233                if self._panelManager is not None:
234                    w += self._panelManager.getRightInset()
235
236                return w, h
237            except Exception as e:
238                logger.warning(f"Failed to compute screen-based window size: {e}")
239                return 900, 600
240
241        def paintCanvas(sender=None):
242            """
243            Render the function output to the canvas and update the PDF document.
244            """
245            start = perf_counter()
246
247            # Phase 2: Skip expensive PDF re-render if panel state is unchanged.
248            currentState = self._getRenderableState()
249            if currentState != self._lastRenderedState:
250                logger.trace("Panel state changed — re-rendering PDF")
251                drawBot.newDrawing()
252                self._invokeRenderFunction(func)
253                self._lastRenderedPDF = drawBot.pdfImage()
254                drawBot.endDrawing()
255                self._lastRenderedState = currentState
256            else:
257                logger.trace("State unchanged — skipping PDF render")
258
259            # Set the unflipped PDF directly — flip is handled via layer transform below.
260            if self._lastRenderedPDF is not None:
261                logger.trace("Updating canvas PDF document")
262                self.w.canvas.setPDFDocument(self._lastRenderedPDF)
263
264            # Always re-fit scale: canvas area may have changed (e.g. panel toggle).
265            nsView = self.w.canvas.getNSView()
266            fitScale = nsView.scaleFactorForSizeToFit()
267            targetScale = (
268                fitScale * self.AUTO_FIT_MARGIN
269                if self._canvasScale == 1
270                else self._canvasScale
271            )
272            self.w.canvas.setScale(targetScale)
273
274            # Apply flip AFTER setScale so the layer transform isn't overwritten.
275            panelState = self.getPanelState()
276            flipX = bool(panelState.get("flipX", False))
277            flipY = bool(panelState.get("flipY", False))
278            self._applyFlipTransform(flipX, flipY)
279
280            logger.trace(
281                "Canvas fit metrics: targetScale={:.4f}, fitScale={:.4f}, canvasScale={:.4f}",
282                targetScale,
283                fitScale,
284                self._canvasScale,
285            )
286
287            elapsed = perf_counter() - start
288            logger.info(f"⏱️ Paint took {elapsed:.4f} seconds")
289
290        def forceRender(sender=None):
291            """Force a full re-render, bypassing the state-diff optimisation."""
292            self._debouncer.cancel()
293            self._lastRenderedState = None
294            paintCanvas()
295
296        # Phase 1: set up Cocoa-native debouncer for panel changes.
297        self._debouncer = _PaintDebouncer.alloc().init()
298        self._debouncer.setup(paintCanvas)
299
300        def onPanelChanged(immediate=False):
301            if self._panelManager is not None:
302                self._panelManager.applyUILayout()
303            if immediate:
304                self._debouncer.cancel()
305                paintCanvas()
306            else:
307                self._debouncer.schedule(self._debounceDelay)
308
309        self.w = vanilla.Window(
310            getScreenSize(),
311            minSize=(400, 400),
312        )
313
314        if self._ui:
315            self.w.refreshBtn = vanilla.Button(
316                (-196, -22, 60, 14),
317                "Refresh",
318                sizeStyle="mini",
319                callback=forceRender,
320            )
321            self.w.saveBtn = vanilla.Button(
322                (-132, -22, 60, 14),
323                "Save File",
324                sizeStyle="mini",
325                callback=self.savePdf,
326            )
327            self.w.printBtn = vanilla.Button(
328                (-68, -22, 60, 14),
329                "Print",
330                sizeStyle="mini",
331                callback=self.printImage,
332            )
333            canvasH = -self.UI_PANEL_HEIGHT
334        else:
335            canvasH = -0
336
337        self.w.canvas = DrawView((0, 0, -0, canvasH))
338
339        if self._panelManager is not None:
340            self._panelManager.build(
341                self.w,
342                canvasH,
343                onPanelChanged,
344                onForceRender=forceRender,
345            )
346
347        paintCanvas()
348
349        self.w.open()
350        self.w.center()
351
352    def _getRenderableState(self) -> dict:
353        """Panel state excluding view-transform keys that don't affect PDF content."""
354        state = self.getPanelState()
355        return {k: v for k, v in state.items() if k not in ("flipX", "flipY")}
356
357    def _applyFlipTransform(self, flipX: bool, flipY: bool):
358        """Flip the canvas view via a CALayer affine transform — no PDF rewriting."""
359        try:
360            from Quartz import CGAffineTransformMakeScale
361
362            nsView = self.w.canvas.getNSView()
363            nsView.setWantsLayer_(True)
364            layer = nsView.layer()
365            if layer is None:
366                return
367
368            # Compute the layer's frame center in parent coords regardless of the
369            # current anchor point (AppKit-backed layers often anchor at (0,0)).
370            bounds = layer.bounds()
371            pos = layer.position()
372            anchor = layer.anchorPoint()
373            bw, bh = bounds.size.width, bounds.size.height
374            cx = (pos.x - anchor.x * bw) + bw / 2.0
375            cy = (pos.y - anchor.y * bh) + bh / 2.0
376
377            # Pin anchor to center so scale(±1) flips around the middle of the view.
378            layer.setAnchorPoint_((0.5, 0.5))
379            layer.setPosition_((cx, cy))
380
381            sx = -1.0 if flipX else 1.0
382            sy = -1.0 if flipY else 1.0
383            layer.setAffineTransform_(CGAffineTransformMakeScale(sx, sy))
384        except Exception as e:
385            logger.warning(f"Failed to apply flip transform: {e}")
386
387    def savePdf(self, sender):
388        """
389        Save the current canvas as a PDF, SVG, or PNG file.
390        """
391        path = dialogs.putFile(fileTypes=["pdf", "svg", "png"])
392        if path:
393            drawBot.saveImage(path)
394
395    def printImage(self, sender):
396        """Send the current drawing to the system print dialog."""
397        drawBot.printImage()

Execute a function in a GUI window using DrawBot and Vanilla.

RenderView( func: Callable, windowRatio: Union[Literal['A3', 'A3Landscape', 'A4', 'A4Landscape', 'A4Small', 'A4SmallLandscape', 'A5', 'A5Landscape', 'B4', 'B4Landscape', 'B5', 'B5Landscape'], str, float, tuple[int, int]] = 0.6666666666666666, windowScale: int = 1, canvasScale: int = 1, ui: bool = True, panelManager: classes.c91_render_panel_manager.RenderPanelManager | None = None, debounceDelay: float = 0.5)
 69    def __init__(
 70        self,
 71        func: Callable,
 72        windowRatio: layout.AspectRatioInput = 2 / 3,
 73        windowScale: int = 1,
 74        canvasScale: int = 1,
 75        ui: bool = True,
 76        panelManager: RenderPanelManager | None = None,
 77        debounceDelay: float = 0.5,
 78    ):
 79        """
 80        Initialize a GUI environment.
 81
 82        Args:
 83            func: The function to execute and display in the GUI.
 84            windowRatio: The width/height ratio of the window.
 85            windowScale: The scaling factor for the window size relative to the screen. (0-1)
 86            canvasScale: The scale factor for the canvas rendering.
 87            ui: Whether to show UI controls (refresh and save buttons).
 88            panelManager: Optional side panel manager object.
 89            debounceDelay: Seconds to wait after a panel change before re-rendering (default: 0.5).
 90        """
 91        self._windowRatio = layout.parseAspectRatio(windowRatio)
 92        if windowScale <= 0 or windowScale > 1:
 93            raise ValueError(
 94                "windowScale must be between 0 (exclusive) and 1 (inclusive)."
 95            )
 96        self._windowScale = windowScale
 97        self._canvasScale = canvasScale
 98        self._ui = ui
 99        self._panelManager = panelManager
100        self._debounceDelay = debounceDelay
101        self._lastRenderedState: dict | None = None
102        self._lastRenderedPDF = None
103        self._debouncer: _PaintDebouncer | None = None
104
105        if self._isRunningInDrawBotApp():
106            self.run(func)
107        else:
108            executeVanillaTest(self.run(func))

Initialize a GUI environment.

Arguments:
  • func: The function to execute and display in the GUI.
  • windowRatio: The width/height ratio of the window.
  • windowScale: The scaling factor for the window size relative to the screen. (0-1)
  • canvasScale: The scale factor for the canvas rendering.
  • ui: Whether to show UI controls (refresh and save buttons).
  • panelManager: Optional side panel manager object.
  • debounceDelay: Seconds to wait after a panel change before re-rendering (default: 0.5).
UI_PANEL_HEIGHT = 26
AUTO_FIT_MARGIN = 0.992
def getPanelState(self) -> dict[str, typing.Any]:
110    def getPanelState(self) -> dict[str, Any]:
111        """Return active panel plugin state, or an empty dict."""
112        if self._panelManager is None:
113            return {}
114        return self._panelManager.getState()

Return active panel plugin state, or an empty dict.

def run(self, func):
141    def run(self, func):
142        """
143        Set up and run the GUI window, rendering the provided function.
144
145        Args:
146            func: The function to execute and render on the canvas.
147        """
148
149        def getPlacementScreen():
150            """Determine which screen the mouse is currently on to place the window there."""
151            if self._isRunningInDrawBotApp():
152                logger.debug("DrawBot.app mode: using main screen (no mouse probe)")
153                return NSScreen.mainScreen()
154            try:
155                mouse = NSEvent.mouseLocation()
156                screens = NSScreen.screens()
157                for screen in screens:
158                    frame = screen.frame()
159                    left = frame.origin.x
160                    bottom = frame.origin.y
161                    right = left + frame.size.width
162                    top = bottom + frame.size.height
163                    if left <= mouse.x <= right and bottom <= mouse.y <= top:
164                        return screen
165            except Exception as e:
166                logger.warning(
167                    f"Failed to get placement screen from mouse position: {e}"
168                )
169            return NSScreen.mainScreen()
170
171        def getWindowChromeHeight():
172            """Measure current window chrome height (frame minus content) in points."""
173            if self._isRunningInDrawBotApp():
174                logger.debug(
175                    "DrawBot.app mode: skipping chrome probe, using fallback 28"
176                )
177                return 28
178
179            probe = None
180            try:
181                probe = vanilla.Window((200, 200))
182                nsWindow = probe.getNSWindow()
183                frame = nsWindow.frame()
184                content = nsWindow.contentRectForFrameRect_(frame)
185                return frame.size.height - content.size.height
186            except Exception as e:
187                logger.warning(
188                    f"Failed to measure window chrome height, using fallback 28: {e}"
189                )
190                return 28
191            finally:
192                if probe is not None:
193                    try:
194                        probe.close()
195                    except Exception:
196                        pass
197
198        windowChromeH = getWindowChromeHeight()
199
200        def getScreenSize():
201            """
202            Calculate the window size based on usable screen area (without MacOS menu bar and dock) and windowRatio/windowScale.
203
204            Returns:
205                tuple: (window width, window height)
206            """
207            try:
208                visible = getPlacementScreen().visibleFrame()
209                availW, availH = visible.size.width, visible.size.height
210
211                contentAvailH = (
212                    availH - windowChromeH
213                )  # Subtract space for OS window chrome
214                if self._ui:
215                    contentAvailH -= self.UI_PANEL_HEIGHT  # Subtract space for UI panel
216
217                # Use usable width/height so left/right dock positions are accounted for.
218                screenAspectRatio = availW / contentAvailH
219
220                isWiderThanScreen = self._windowRatio > screenAspectRatio
221
222                if isWiderThanScreen:
223                    w = availW * self._windowScale
224                    h = w / self._windowRatio
225                else:
226                    h = contentAvailH * self._windowScale
227                    w = h * self._windowRatio
228
229                h += windowChromeH  # Add back space for OS window chrome
230                if self._ui:
231                    h += self.UI_PANEL_HEIGHT  # Add back space for UI panel
232
233                if self._panelManager is not None:
234                    w += self._panelManager.getRightInset()
235
236                return w, h
237            except Exception as e:
238                logger.warning(f"Failed to compute screen-based window size: {e}")
239                return 900, 600
240
241        def paintCanvas(sender=None):
242            """
243            Render the function output to the canvas and update the PDF document.
244            """
245            start = perf_counter()
246
247            # Phase 2: Skip expensive PDF re-render if panel state is unchanged.
248            currentState = self._getRenderableState()
249            if currentState != self._lastRenderedState:
250                logger.trace("Panel state changed — re-rendering PDF")
251                drawBot.newDrawing()
252                self._invokeRenderFunction(func)
253                self._lastRenderedPDF = drawBot.pdfImage()
254                drawBot.endDrawing()
255                self._lastRenderedState = currentState
256            else:
257                logger.trace("State unchanged — skipping PDF render")
258
259            # Set the unflipped PDF directly — flip is handled via layer transform below.
260            if self._lastRenderedPDF is not None:
261                logger.trace("Updating canvas PDF document")
262                self.w.canvas.setPDFDocument(self._lastRenderedPDF)
263
264            # Always re-fit scale: canvas area may have changed (e.g. panel toggle).
265            nsView = self.w.canvas.getNSView()
266            fitScale = nsView.scaleFactorForSizeToFit()
267            targetScale = (
268                fitScale * self.AUTO_FIT_MARGIN
269                if self._canvasScale == 1
270                else self._canvasScale
271            )
272            self.w.canvas.setScale(targetScale)
273
274            # Apply flip AFTER setScale so the layer transform isn't overwritten.
275            panelState = self.getPanelState()
276            flipX = bool(panelState.get("flipX", False))
277            flipY = bool(panelState.get("flipY", False))
278            self._applyFlipTransform(flipX, flipY)
279
280            logger.trace(
281                "Canvas fit metrics: targetScale={:.4f}, fitScale={:.4f}, canvasScale={:.4f}",
282                targetScale,
283                fitScale,
284                self._canvasScale,
285            )
286
287            elapsed = perf_counter() - start
288            logger.info(f"⏱️ Paint took {elapsed:.4f} seconds")
289
290        def forceRender(sender=None):
291            """Force a full re-render, bypassing the state-diff optimisation."""
292            self._debouncer.cancel()
293            self._lastRenderedState = None
294            paintCanvas()
295
296        # Phase 1: set up Cocoa-native debouncer for panel changes.
297        self._debouncer = _PaintDebouncer.alloc().init()
298        self._debouncer.setup(paintCanvas)
299
300        def onPanelChanged(immediate=False):
301            if self._panelManager is not None:
302                self._panelManager.applyUILayout()
303            if immediate:
304                self._debouncer.cancel()
305                paintCanvas()
306            else:
307                self._debouncer.schedule(self._debounceDelay)
308
309        self.w = vanilla.Window(
310            getScreenSize(),
311            minSize=(400, 400),
312        )
313
314        if self._ui:
315            self.w.refreshBtn = vanilla.Button(
316                (-196, -22, 60, 14),
317                "Refresh",
318                sizeStyle="mini",
319                callback=forceRender,
320            )
321            self.w.saveBtn = vanilla.Button(
322                (-132, -22, 60, 14),
323                "Save File",
324                sizeStyle="mini",
325                callback=self.savePdf,
326            )
327            self.w.printBtn = vanilla.Button(
328                (-68, -22, 60, 14),
329                "Print",
330                sizeStyle="mini",
331                callback=self.printImage,
332            )
333            canvasH = -self.UI_PANEL_HEIGHT
334        else:
335            canvasH = -0
336
337        self.w.canvas = DrawView((0, 0, -0, canvasH))
338
339        if self._panelManager is not None:
340            self._panelManager.build(
341                self.w,
342                canvasH,
343                onPanelChanged,
344                onForceRender=forceRender,
345            )
346
347        paintCanvas()
348
349        self.w.open()
350        self.w.center()

Set up and run the GUI window, rendering the provided function.

Arguments:
  • func: The function to execute and render on the canvas.
def savePdf(self, sender):
387    def savePdf(self, sender):
388        """
389        Save the current canvas as a PDF, SVG, or PNG file.
390        """
391        path = dialogs.putFile(fileTypes=["pdf", "svg", "png"])
392        if path:
393            drawBot.saveImage(path)

Save the current canvas as a PDF, SVG, or PNG file.

def printImage(self, sender):
395    def printImage(self, sender):
396        """Send the current drawing to the system print dialog."""
397        drawBot.printImage()

Send the current drawing to the system print dialog.