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).
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.